diff --git a/.coveragerc b/.coveragerc index 4aa8ee4949e54b..d17676d79c94af 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 @@ -174,6 +173,7 @@ omit = homeassistant/components/coinbase/sensor.py homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comelit/__init__.py + homeassistant/components/comelit/alarm_control_panel.py homeassistant/components/comelit/const.py homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py @@ -216,9 +216,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,11 +335,9 @@ 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 - homeassistant/components/esphome/bluetooth/* homeassistant/components/esphome/manager.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* @@ -369,7 +364,6 @@ omit = homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/faa_delays/coordinator.py homeassistant/components/familyhub/camera.py - homeassistant/components/fastdotcom/* homeassistant/components/ffmpeg/camera.py homeassistant/components/fibaro/__init__.py homeassistant/components/fibaro/binary_sensor.py @@ -408,6 +402,9 @@ omit = homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py + homeassistant/components/flexit_bacnet/__init__.py + homeassistant/components/flexit_bacnet/const.py + homeassistant/components/flexit_bacnet/climate.py homeassistant/components/flic/binary_sensor.py homeassistant/components/flick_electric/__init__.py homeassistant/components/flick_electric/sensor.py @@ -423,12 +420,11 @@ omit = homeassistant/components/fortios/device_tracker.py homeassistant/components/foscam/__init__.py homeassistant/components/foscam/camera.py + homeassistant/components/foscam/coordinator.py 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 @@ -542,6 +538,7 @@ omit = homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py homeassistant/components/ialarm/alarm_control_panel.py + homeassistant/components/iammeter/const.py homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py homeassistant/components/iaqualink/climate.py @@ -639,8 +636,6 @@ omit = homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/media_player.py homeassistant/components/kodi/notify.py - homeassistant/components/komfovent/__init__.py - homeassistant/components/komfovent/climate.py homeassistant/components/konnected/__init__.py homeassistant/components/konnected/panel.py homeassistant/components/konnected/switch.py @@ -762,6 +757,9 @@ omit = homeassistant/components/motion_blinds/cover.py homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py + homeassistant/components/motionmount/__init__.py + homeassistant/components/motionmount/entity.py + homeassistant/components/motionmount/number.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py homeassistant/components/msteams/notify.py @@ -807,7 +805,8 @@ omit = homeassistant/components/netgear/sensor.py homeassistant/components/netgear/switch.py homeassistant/components/netgear/update.py - homeassistant/components/netgear_lte/* + homeassistant/components/netgear_lte/__init__.py + homeassistant/components/netgear_lte/notify.py homeassistant/components/netio/switch.py homeassistant/components/neurio_energy/sensor.py homeassistant/components/nexia/climate.py @@ -835,6 +834,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 @@ -907,6 +907,9 @@ omit = homeassistant/components/opple/light.py homeassistant/components/oru/* homeassistant/components/orvibo/switch.py + homeassistant/components/osoenergy/__init__.py + homeassistant/components/osoenergy/const.py + homeassistant/components/osoenergy/water_heater.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py homeassistant/components/overkiz/__init__.py @@ -935,6 +938,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 @@ -948,8 +954,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 @@ -1032,6 +1036,12 @@ omit = homeassistant/components/recorder/repack.py homeassistant/components/recswitch/switch.py homeassistant/components/reddit/sensor.py + homeassistant/components/refoss/__init__.py + homeassistant/components/refoss/bridge.py + homeassistant/components/refoss/coordinator.py + homeassistant/components/refoss/entity.py + homeassistant/components/refoss/switch.py + homeassistant/components/refoss/util.py homeassistant/components/rejseplanen/sensor.py homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote_rpi_gpio/* @@ -1131,10 +1141,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 @@ -1218,6 +1225,7 @@ omit = homeassistant/components/starline/__init__.py homeassistant/components/starline/account.py homeassistant/components/starline/binary_sensor.py + homeassistant/components/starline/button.py homeassistant/components/starline/device_tracker.py homeassistant/components/starline/entity.py homeassistant/components/starline/lock.py @@ -1235,8 +1243,12 @@ omit = homeassistant/components/stream/fmp4utils.py homeassistant/components/stream/hls.py homeassistant/components/stream/worker.py - homeassistant/components/streamlabswater/* - homeassistant/components/suez_water/* + homeassistant/components/streamlabswater/__init__.py + homeassistant/components/streamlabswater/binary_sensor.py + homeassistant/components/streamlabswater/coordinator.py + homeassistant/components/streamlabswater/sensor.py + homeassistant/components/suez_water/__init__.py + homeassistant/components/suez_water/sensor.py homeassistant/components/supervisord/sensor.py homeassistant/components/supla/* homeassistant/components/surepetcare/__init__.py @@ -1244,6 +1256,8 @@ omit = homeassistant/components/surepetcare/entity.py homeassistant/components/surepetcare/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py + homeassistant/components/swiss_public_transport/__init__.py + homeassistant/components/swiss_public_transport/coordinator.py homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbee/__init__.py @@ -1295,13 +1309,16 @@ omit = homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py homeassistant/components/system_bridge/update.py + homeassistant/components/systemmonitor/__init__.py homeassistant/components/systemmonitor/sensor.py + homeassistant/components/systemmonitor/util.py homeassistant/components/tado/__init__.py homeassistant/components/tado/binary_sensor.py homeassistant/components/tado/climate.py homeassistant/components/tado/device_tracker.py homeassistant/components/tado/sensor.py homeassistant/components/tado/water_heater.py + homeassistant/components/tami4/button.py homeassistant/components/tank_utility/sensor.py homeassistant/components/tankerkoenig/__init__.py homeassistant/components/tankerkoenig/binary_sensor.py @@ -1384,10 +1401,6 @@ omit = homeassistant/components/tradfri/light.py homeassistant/components/tradfri/sensor.py homeassistant/components/tradfri/switch.py - homeassistant/components/trafikverket_train/__init__.py - homeassistant/components/trafikverket_train/coordinator.py - homeassistant/components/trafikverket_train/sensor.py - homeassistant/components/trafikverket_train/util.py homeassistant/components/trafikverket_weatherstation/__init__.py homeassistant/components/trafikverket_weatherstation/coordinator.py homeassistant/components/trafikverket_weatherstation/sensor.py @@ -1422,6 +1435,8 @@ omit = homeassistant/components/ukraine_alarm/__init__.py homeassistant/components/ukraine_alarm/binary_sensor.py homeassistant/components/unifiled/* + homeassistant/components/unifi_direct/__init__.py + homeassistant/components/unifi_direct/device_tracker.py homeassistant/components/upb/__init__.py homeassistant/components/upb/light.py homeassistant/components/upc_connect/* @@ -1433,6 +1448,7 @@ omit = 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 @@ -1474,6 +1490,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 27e2d2e5ad0d0c..44a81718e10f3c 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 4bc1442d9e9b58..d69b1ac0c7d6b1 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 9d13c07301efd9..4a767d234b58d2 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -102,7 +102,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v2 + uses: dawidd6/action-download-artifact@v3.0.0 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -113,7 +113,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v2 + uses: dawidd6/action-download-artifact@v3.0.0 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package @@ -124,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -197,7 +197,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.09.0 + uses: home-assistant/builder@2024.01.0 with: args: | $BUILD_ARGS \ @@ -247,6 +247,7 @@ jobs: - raspberrypi3-64 - raspberrypi4 - raspberrypi4-64 + - raspberrypi5-64 - tinker - yellow - green @@ -273,7 +274,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.09.0 + uses: home-assistant/builder@2024.01.0 with: args: | $BUILD_ARGS \ @@ -330,7 +331,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Install Cosign - uses: sigstore/cosign-installer@v3.2.0 + uses: sigstore/cosign-installer@v3.3.0 with: cosign-release: "v2.0.2" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 29f0c9ee5d865d..d77d2166e1d145 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.12" + MYPY_CACHE_VERSION: 6 + HA_SHORT_VERSION: "2024.2" 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 @@ -227,7 +225,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -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 @@ -271,18 +269,11 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python 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 @@ -339,7 +309,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -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 @@ -388,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -483,7 +443,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -551,7 +511,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -583,7 +543,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -616,7 +576,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -660,7 +620,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -742,7 +702,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -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 \ @@ -894,7 +854,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -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 \ @@ -1018,7 +978,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -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 ccd2d3c1678822..1dc36b9fa34a66 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.5 + uses: github/codeql-action/init@v3.22.12 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.5 + uses: github/codeql-action/analyze@v3.22.12 with: category: "/language:python" diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 2b5364fa950a98..fb5deb2958f1bc 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 d189a3656a51d3..00000000000000 --- 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/.github/workflows/stale.yml b/.github/workflows/stale.yml index c91117cb02d352..b51550767b8ff2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,16 +11,16 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - # The 90 day stale policy for PRs + # The 60 day stale policy for PRs # Used for: # - PRs # - No PRs marked as no-stale # - No issues (-1) - - name: 90 days stale PRs policy - uses: actions/stale@v8.0.0 + - name: 60 days stale PRs policy + uses: actions/stale@v9.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-stale: 90 + days-before-stale: 60 days-before-close: 7 days-before-issue-stale: -1 days-before-issue-close: -1 @@ -33,7 +33,11 @@ jobs: pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days. - Thank you for your contributions. + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. + + Thank you for your contribution! # Generate a token for the GitHub App, we use this method to avoid # hitting API limits for our GitHub actions + have a higher rate limit. @@ -53,7 +57,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -83,7 +87,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index f72b71b8802f85..c8e25cc83ea548 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d43bcf1b02cd4..79bf7e87903df1 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.8 hooks: - id: ruff args: - --fix - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.11.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 diff --git a/.prettierignore b/.prettierignore index 07637a380c5aa3..b249b537137533 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 b2f27fafbbc21e..e46f439e4ca9f4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -42,39 +42,67 @@ homeassistant.components homeassistant.components.abode.* homeassistant.components.accuweather.* homeassistant.components.acer_projector.* +homeassistant.components.acmeda.* homeassistant.components.actiontec.* +homeassistant.components.adax.* homeassistant.components.adguard.* homeassistant.components.aftership.* homeassistant.components.air_quality.* homeassistant.components.airly.* +homeassistant.components.airnow.* +homeassistant.components.airq.* +homeassistant.components.airthings.* +homeassistant.components.airthings_ble.* homeassistant.components.airvisual.* +homeassistant.components.airvisual_pro.* homeassistant.components.airzone.* homeassistant.components.airzone_cloud.* homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.alert.* homeassistant.components.alexa.* +homeassistant.components.alpha_vantage.* homeassistant.components.amazon_polly.* +homeassistant.components.amberelectric.* +homeassistant.components.ambiclimate.* homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.analytics.* +homeassistant.components.android_ip_webcam.* +homeassistant.components.androidtv.* +homeassistant.components.androidtv_remote.* +homeassistant.components.anel_pwrctrl.* homeassistant.components.anova.* homeassistant.components.anthemav.* +homeassistant.components.apache_kafka.* homeassistant.components.apcupsd.* +homeassistant.components.apprise.* +homeassistant.components.aprs.* homeassistant.components.aqualogic.* +homeassistant.components.aquostv.* +homeassistant.components.aranet.* +homeassistant.components.arcam_fmj.* +homeassistant.components.arris_tg2492lg.* +homeassistant.components.aruba.* +homeassistant.components.arwn.* homeassistant.components.aseko_pool_live.* homeassistant.components.assist_pipeline.* +homeassistant.components.asterisk_cdr.* +homeassistant.components.asterisk_mbox.* homeassistant.components.asuswrt.* homeassistant.components.auth.* homeassistant.components.automation.* homeassistant.components.awair.* +homeassistant.components.axis.* homeassistant.components.backup.* homeassistant.components.baf.* homeassistant.components.bayesian.* homeassistant.components.binary_sensor.* homeassistant.components.bitcoin.* homeassistant.components.blockchain.* +homeassistant.components.blue_current.* +homeassistant.components.blueprint.* homeassistant.components.bluetooth.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* @@ -90,10 +118,15 @@ homeassistant.components.clickatell.* homeassistant.components.clicksend.* homeassistant.components.climate.* homeassistant.components.cloud.* +homeassistant.components.co2signal.* +homeassistant.components.command_line.* homeassistant.components.configurator.* +homeassistant.components.counter.* homeassistant.components.cover.* homeassistant.components.cpuspeed.* homeassistant.components.crownstone.* +homeassistant.components.date.* +homeassistant.components.datetime.* homeassistant.components.deconz.* homeassistant.components.demo.* homeassistant.components.derivative.* @@ -117,9 +150,12 @@ homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* homeassistant.components.energy.* +homeassistant.components.enigma2.* homeassistant.components.esphome.* homeassistant.components.event.* homeassistant.components.evil_genius_labs.* +homeassistant.components.evohome.* +homeassistant.components.faa_delays.* homeassistant.components.fan.* homeassistant.components.fastdotcom.* homeassistant.components.feedreader.* @@ -127,6 +163,7 @@ homeassistant.components.file_upload.* homeassistant.components.filesize.* homeassistant.components.filter.* homeassistant.components.fitbit.* +homeassistant.components.flexit_bacnet.* homeassistant.components.flux_led.* homeassistant.components.forecast_solar.* homeassistant.components.fritz.* @@ -150,6 +187,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.holiday.* homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* @@ -170,6 +208,7 @@ homeassistant.components.homekit_controller.utils homeassistant.components.homewizard.* homeassistant.components.http.* homeassistant.components.huawei_lte.* +homeassistant.components.humidifier.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* @@ -202,6 +241,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.* @@ -227,6 +267,7 @@ homeassistant.components.modbus.* homeassistant.components.modem_callerid.* homeassistant.components.moon.* homeassistant.components.mopeka.* +homeassistant.components.motionmount.* homeassistant.components.mqtt.* homeassistant.components.mysensors.* homeassistant.components.nam.* @@ -263,6 +304,7 @@ homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* homeassistant.components.purpleair.* +homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* homeassistant.components.radarr.* @@ -298,6 +340,7 @@ homeassistant.components.sfr_box.* homeassistant.components.shelly.* homeassistant.components.simplepush.* homeassistant.components.simplisafe.* +homeassistant.components.siren.* homeassistant.components.skybell.* homeassistant.components.slack.* homeassistant.components.sleepiq.* @@ -312,6 +355,9 @@ homeassistant.components.statistics.* homeassistant.components.steamist.* homeassistant.components.stookalert.* homeassistant.components.stream.* +homeassistant.components.streamlabswater.* +homeassistant.components.stt.* +homeassistant.components.suez_water.* homeassistant.components.sun.* homeassistant.components.surepetcare.* homeassistant.components.switch.* @@ -322,14 +368,19 @@ homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* homeassistant.components.tailscale.* +homeassistant.components.tailwind.* homeassistant.components.tami4.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.tedee.* homeassistant.components.text.* homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* homeassistant.components.tilt_ble.* +homeassistant.components.time.* +homeassistant.components.time_date.* +homeassistant.components.todo.* homeassistant.components.tolo.* homeassistant.components.tplink.* homeassistant.components.tplink_omada.* @@ -352,9 +403,11 @@ homeassistant.components.uptimerobot.* homeassistant.components.usb.* homeassistant.components.vacuum.* homeassistant.components.vallox.* +homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* homeassistant.components.wake_on_lan.* +homeassistant.components.wake_word.* homeassistant.components.wallbox.* homeassistant.components.water_heater.* homeassistant.components.watttime.* diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 951134133e57e6..8a5d7d486b73b8 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/settings.default.json b/.vscode/settings.default.json index 3765d1251b807a..e0792a360f1af6 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 e587d75d799244..d8387c634eef3b 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 f6737c2e044fc8..21d692d2942bcb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -86,6 +86,8 @@ build.json @home-assistant/supervisor /tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex +/homeassistant/components/aosmith/ @bdr99 +/tests/components/aosmith/ @bdr99 /homeassistant/components/apache_kafka/ @bachya /tests/components/apache_kafka/ @bachya /homeassistant/components/apcupsd/ @yuxincs @@ -151,8 +153,10 @@ 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/blue_current/ @Floris272 @gleeuwen +/tests/components/blue_current/ @Floris272 @gleeuwen /homeassistant/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco /homeassistant/components/blueprint/ @home-assistant/core @@ -170,8 +174,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 @@ -193,6 +197,8 @@ build.json @home-assistant/supervisor /tests/components/camera/ @home-assistant/core /homeassistant/components/cast/ @emontnemery /tests/components/cast/ @emontnemery +/homeassistant/components/ccm15/ @ocalvo +/tests/components/ccm15/ @ocalvo /homeassistant/components/cert_expiry/ @jjlawren /tests/components/cert_expiry/ @jjlawren /homeassistant/components/circuit/ @braam @@ -205,8 +211,8 @@ build.json @home-assistant/supervisor /tests/components/cloud/ @home-assistant/cloud /homeassistant/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington -/homeassistant/components/co2signal/ @jpbede -/tests/components/co2signal/ @jpbede +/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99 +/tests/components/co2signal/ @jpbede @VIKTORVAV99 /homeassistant/components/coinbase/ @tombrien /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent @@ -259,6 +265,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 @@ -293,6 +301,8 @@ build.json @home-assistant/supervisor /tests/components/dormakaba_dkey/ @emontnemery /homeassistant/components/dremel_3d_printer/ @tkdrob /tests/components/dremel_3d_printer/ @tkdrob +/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer +/tests/components/drop_connect/ @ChandlerSystems @pfrazer /homeassistant/components/dsmr/ @Robbie1221 @frenck /tests/components/dsmr/ @Robbie1221 @frenck /homeassistant/components/dsmr_reader/ @depl0y @glodenox @@ -307,8 +317,8 @@ 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/ @w1ll1am23 @@ -342,20 +352,18 @@ build.json @home-assistant/supervisor /tests/components/energy/ @home-assistant/core /homeassistant/components/energyzero/ @klaasnicolaas /tests/components/energyzero/ @klaasnicolaas -/homeassistant/components/enigma2/ @fbradyirl +/homeassistant/components/enigma2/ @autinerd /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 +381,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 @@ -394,6 +403,8 @@ build.json @home-assistant/supervisor /tests/components/fivem/ @Sander0542 /homeassistant/components/fjaraskupan/ @elupus /tests/components/fjaraskupan/ @elupus +/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski +/tests/components/flexit_bacnet/ @lellky @piotrbulinski /homeassistant/components/flick_electric/ @ZephireNZ /tests/components/flick_electric/ @ZephireNZ /homeassistant/components/flipr/ @cnico @@ -409,8 +420,8 @@ build.json @home-assistant/supervisor /homeassistant/components/forked_daapd/ @uvjustin /tests/components/forked_daapd/ @uvjustin /homeassistant/components/fortios/ @kimfrellsen -/homeassistant/components/foscam/ @skgsergio -/tests/components/foscam/ @skgsergio +/homeassistant/components/foscam/ @skgsergio @krmarien +/tests/components/foscam/ @skgsergio @krmarien /homeassistant/components/freebox/ @hacf-fr @Quentame /tests/components/freebox/ @hacf-fr @Quentame /homeassistant/components/freedompro/ @stefano055415 @@ -490,8 +501,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 @@ -521,6 +530,8 @@ build.json @home-assistant/supervisor /tests/components/hive/ @Rendili @KJonline /homeassistant/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard +/homeassistant/components/holiday/ @jrieger @gjohansson-ST +/tests/components/holiday/ @jrieger @gjohansson-ST /homeassistant/components/home_connect/ @DavidMStraub /tests/components/home_connect/ @DavidMStraub /homeassistant/components/home_plus_control/ @chemaaa @@ -664,8 +675,6 @@ build.json @home-assistant/supervisor /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund -/homeassistant/components/komfovent/ @ProstoSanja -/tests/components/komfovent/ @ProstoSanja /homeassistant/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm @@ -701,6 +710,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 @@ -804,6 +815,8 @@ build.json @home-assistant/supervisor /tests/components/motion_blinds/ @starkillerOG /homeassistant/components/motioneye/ @dermotduffy /tests/components/motioneye/ @dermotduffy +/homeassistant/components/motionmount/ @RJPoelstra +/tests/components/motionmount/ @RJPoelstra /homeassistant/components/mqtt/ @emontnemery @jbouwh /tests/components/mqtt/ @emontnemery @jbouwh /homeassistant/components/msteams/ @peroyvind @@ -834,6 +847,7 @@ build.json @home-assistant/supervisor /homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG /tests/components/netgear/ @hacf-fr @Quentame @starkillerOG /homeassistant/components/netgear_lte/ @tkdrob +/tests/components/netgear_lte/ @tkdrob /homeassistant/components/network/ @home-assistant/core /tests/components/network/ @home-assistant/core /homeassistant/components/nexia/ @bdraco @@ -927,8 +941,12 @@ build.json @home-assistant/supervisor /homeassistant/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/oru/ @bvlaicu +/homeassistant/components/osoenergy/ @osohotwateriot +/tests/components/osoenergy/ @osohotwateriot /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 +961,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,9 +999,11 @@ 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 +/homeassistant/components/prusalink/ @balloob @Skaronator +/tests/components/prusalink/ @balloob @Skaronator /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 /homeassistant/components/pure_energie/ @klaasnicolaas @@ -998,8 +1020,8 @@ build.json @home-assistant/supervisor /tests/components/pvoutput/ @frenck /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue -/homeassistant/components/qbittorrent/ @geoffreylagaisse -/tests/components/qbittorrent/ @geoffreylagaisse +/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 +/tests/components/qbittorrent/ @geoffreylagaisse @finder39 /homeassistant/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte @@ -1012,8 +1034,8 @@ build.json @home-assistant/supervisor /homeassistant/components/qvr_pro/ @oblogic7 /homeassistant/components/qwikswitch/ @kellerza /tests/components/qwikswitch/ @kellerza -/homeassistant/components/rachio/ @bdraco -/tests/components/rachio/ @bdraco +/homeassistant/components/rachio/ @bdraco @rfverbruggen +/tests/components/rachio/ @bdraco @rfverbruggen /homeassistant/components/radarr/ @tkdrob /tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck @@ -1041,6 +1063,8 @@ build.json @home-assistant/supervisor /tests/components/recorder/ @home-assistant/core /homeassistant/components/recovery_mode/ @home-assistant/core /tests/components/recovery_mode/ @home-assistant/core +/homeassistant/components/refoss/ @ashionky +/tests/components/refoss/ @ashionky /homeassistant/components/rejseplanen/ @DarkFox /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core @@ -1052,7 +1076,9 @@ 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/rest_command/ @jpbede +/tests/components/rest_command/ @jpbede /homeassistant/components/rflink/ @javicalle /tests/components/rflink/ @javicalle /homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 @@ -1233,18 +1259,22 @@ 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 +/tests/components/suez_water/ @ooii /homeassistant/components/sun/ @Swamp-Ig /tests/components/sun/ @Swamp-Ig +/homeassistant/components/sunweg/ @rokam +/tests/components/sunweg/ @rokam /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen /homeassistant/components/swiss_hydrological_data/ @fabaff -/homeassistant/components/swiss_public_transport/ @fabaff +/homeassistant/components/swiss_public_transport/ @fabaff @miaucl +/tests/components/swiss_public_transport/ @fabaff @miaucl /homeassistant/components/switch/ @home-assistant/core /tests/components/switch/ @home-assistant/core /homeassistant/components/switch_as_x/ @home-assistant/core @@ -1267,12 +1297,16 @@ build.json @home-assistant/supervisor /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 -/homeassistant/components/tado/ @michaelarnauts @chiefdragon -/tests/components/tado/ @michaelarnauts @chiefdragon +/homeassistant/components/systemmonitor/ @gjohansson-ST +/tests/components/systemmonitor/ @gjohansson-ST +/homeassistant/components/tado/ @michaelarnauts @chiefdragon @erwindouna +/tests/components/tado/ @michaelarnauts @chiefdragon @erwindouna /homeassistant/components/tag/ @balloob @dmulcahey /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck /tests/components/tailscale/ @frenck +/homeassistant/components/tailwind/ @frenck +/tests/components/tailwind/ @frenck /homeassistant/components/tami4/ @Guy293 /tests/components/tami4/ @Guy293 /homeassistant/components/tankerkoenig/ @guillempages @mib1185 @@ -1282,12 +1316,16 @@ build.json @home-assistant/supervisor /tests/components/tasmota/ @emontnemery /homeassistant/components/tautulli/ @ludeeus @tkdrob /tests/components/tautulli/ @ludeeus @tkdrob +/homeassistant/components/tedee/ @patrickhilker @zweckj +/tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike /homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /homeassistant/components/tesla_wall_connector/ @einarhauks /tests/components/tesla_wall_connector/ @einarhauks +/homeassistant/components/tessie/ @Bre77 +/tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core /homeassistant/components/tfiac/ @fredrike @mellado @@ -1319,8 +1357,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 @@ -1341,8 +1379,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 @@ -1355,6 +1393,7 @@ build.json @home-assistant/supervisor /tests/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/unifi/ @Kane610 /tests/components/unifi/ @Kane610 +/homeassistant/components/unifi_direct/ @tofuSCHNITZEL /homeassistant/components/unifiled/ @florisvdk /homeassistant/components/unifiprotect/ @AngellusMortis @bdraco /tests/components/unifiprotect/ @AngellusMortis @bdraco @@ -1383,6 +1422,8 @@ build.json @home-assistant/supervisor /tests/components/vacuum/ @home-assistant/core /homeassistant/components/vallox/ @andre-richter @slovdahl @viiru- /tests/components/vallox/ @andre-richter @slovdahl @viiru- +/homeassistant/components/valve/ @home-assistant/core +/tests/components/valve/ @home-assistant/core /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velux/ @Julius2342 @@ -1391,8 +1432,8 @@ build.json @home-assistant/supervisor /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/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja +/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW diff --git a/Dockerfile b/Dockerfile index b61e1461c52baa..43b21ab3ba8be4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,12 @@ +# 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=220000 + S6_SERVICES_GRACETIME=240000 ARG QEMU_CPU diff --git a/Dockerfile.dev b/Dockerfile.dev index 857ccfa3997e72..453b922cd0bfbb 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 @@ -17,6 +16,7 @@ RUN \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ # Additional library needed by some tests and accordingly by VScode Tests Discovery bluez \ + ffmpeg \ libudev-dev \ libavformat-dev \ libavcodec-dev \ diff --git a/build.yaml b/build.yaml index 813676de3a7bb6..824d580913d713 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.1 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.1 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.1 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.1 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.10.1 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.01.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.01.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.01.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.01.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.01.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 2707f8b6899d7a..000dde90faab73 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -280,7 +280,8 @@ async def async_get_or_create_user( 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 e604bf9d21cd2f..32a700d65f9875 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 0aa8807211ae04..cf3632d06d5ab7 100644 --- a/homeassistant/auth/permissions/types.py +++ b/homeassistant/auth/permissions/types.py @@ -5,9 +5,7 @@ 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 bfe8a2fdddb5c4..4ec2ca18611951 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 @@ async def async_user_meta_for_credentials( ) -> 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 0cadbf0758936c..98c246d74e498b 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -10,10 +10,11 @@ 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 @@ 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 6962671cb2fde9..cc195c14c2392c 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -22,6 +22,7 @@ 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 @@ def async_validate_access(self, ip_addr: IPAddress) -> None: 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 b9bb638e052033..83b2f18719f746 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -27,6 +27,7 @@ from .exceptions import HomeAssistantError from .helpers import ( area_registry, + config_validation as cv, device_registry, entity, entity_registry, @@ -41,6 +42,7 @@ DATA_SETUP, DATA_SETUP_STARTED, DATA_SETUP_TIME, + async_notify_setup_error, async_set_domains_to_be_loaded, async_setup_component, ) @@ -292,7 +294,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( @@ -471,7 +474,9 @@ async def async_mount_local_lib_path(config_dir: str) -> str: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" # Filter out the repeating and common config section [homeassistant] - domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN} + domains = { + domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN + } # Add config entry domains if not hass.config.recovery_mode: diff --git a/homeassistant/brands/eq3.json b/homeassistant/brands/eq3.json index 4052afac277a10..f5b1c8aeb87d47 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/brands/flexit.json b/homeassistant/brands/flexit.json new file mode 100644 index 00000000000000..4c61c5eeb07a68 --- /dev/null +++ b/homeassistant/brands/flexit.json @@ -0,0 +1,5 @@ +{ + "domain": "flexit", + "name": "Flexit", + "integrations": ["flexit", "flexit_bacnet"] +} diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 690b38b487170e..839a66af25d8f8 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -11,6 +11,7 @@ import logging from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.helpers.group import expand_entity_ids _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,7 @@ def is_on(hass: HomeAssistant, entity_id: str | None = None) -> bool: If there is no entity id given we will check all. """ if entity_id: - entity_ids = hass.components.group.expand_entity_ids([entity_id]) + entity_ids = expand_entity_ids(hass, [entity_id]) else: entity_ids = hass.states.entity_ids() diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index bceed2154281a0..1b1dbe8b30afd0 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -27,7 +27,7 @@ } -@dataclass +@dataclass(frozen=True) class AbodeSensorDescriptionMixin: """Mixin for Abode sensor.""" @@ -35,7 +35,7 @@ class AbodeSensorDescriptionMixin: native_unit_of_measurement_fn: Callable[[AbodeSense], str] -@dataclass +@dataclass(frozen=True) class AbodeSensorDescription(SensorEntityDescription, AbodeSensorDescriptionMixin): """Class describing Abode sensor entities.""" diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index b74711ccbe6fa3..2974c36607b253 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/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index c983f0bc2911af..2219c5de4b623a 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -45,14 +45,14 @@ PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class AccuWeatherSensorDescriptionMixin: """Mixin for AccuWeather sensor.""" value_fn: Callable[[dict[str, Any]], str | int | float | None] -@dataclass +@dataclass(frozen=True) class AccuWeatherSensorDescription( SensorEntityDescription, AccuWeatherSensorDescriptionMixin ): diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/base.py index 9ad01ba6f29411..5d1f643418afa1 100644 --- a/homeassistant/components/acmeda/base.py +++ b/homeassistant/components/acmeda/base.py @@ -66,12 +66,12 @@ def notify_update(self) -> None: @property def unique_id(self) -> str: """Return the unique ID of this roller.""" - return self.roller.id + return self.roller.id # type: ignore[no-any-return] @property def device_id(self) -> str: """Return the ID of this roller.""" - return self.roller.id + return self.roller.id # type: ignore[no-any-return] @property def device_info(self) -> dr.DeviceInfo: diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 2af985033b6c58..32b6cf31ee55e1 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -30,7 +30,7 @@ async def async_setup_entry( current: set[int] = set() @callback - def async_add_acmeda_covers(): + def async_add_acmeda_covers() -> None: async_add_acmeda_entities( hass, AcmedaCover, config_entry, current, async_add_entities ) @@ -95,7 +95,7 @@ def supported_features(self) -> CoverEntityFeature: @property def is_closed(self) -> bool: """Return if the cover is closed.""" - return self.roller.closed_percent == 100 + return self.roller.closed_percent == 100 # type: ignore[no-any-return] async def async_close_cover(self, **kwargs: Any) -> None: """Close the roller.""" diff --git a/homeassistant/components/acmeda/helpers.py b/homeassistant/components/acmeda/helpers.py index ff8f28ffbc3ee0..a87cbcd163501f 100644 --- a/homeassistant/components/acmeda/helpers.py +++ b/homeassistant/components/acmeda/helpers.py @@ -1,6 +1,8 @@ """Helper functions for Acmeda Pulse.""" from __future__ import annotations +from aiopulse import Roller + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -16,7 +18,7 @@ def async_add_acmeda_entities( config_entry: ConfigEntry, current: set[int], async_add_entities: AddEntitiesCallback, -): +) -> None: """Add any new entities.""" hub = hass.data[DOMAIN][config_entry.entry_id] LOGGER.debug("Looking for new %s on: %s", entity_class.__name__, hub.host) @@ -34,7 +36,9 @@ def async_add_acmeda_entities( async_add_entities(new_items) -async def update_devices(hass: HomeAssistant, config_entry: ConfigEntry, api): +async def update_devices( + hass: HomeAssistant, config_entry: ConfigEntry, api: dict[int, Roller] +) -> None: """Tell hass that device info has been updated.""" dev_registry = dr.async_get(hass) diff --git a/homeassistant/components/acmeda/hub.py b/homeassistant/components/acmeda/hub.py index e156ee5cb7812e..9c6ef6156f0aff 100644 --- a/homeassistant/components/acmeda/hub.py +++ b/homeassistant/components/acmeda/hub.py @@ -2,9 +2,12 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import aiopulse +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ACMEDA_ENTITY_REMOVE, ACMEDA_HUB_UPDATE, LOGGER @@ -14,31 +17,29 @@ class PulseHub: """Manages a single Pulse Hub.""" - def __init__(self, hass, config_entry): + api: aiopulse.Hub + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the system.""" self.config_entry = config_entry self.hass = hass - self.api: aiopulse.Hub | None = None - self.tasks = [] - self.current_rollers = {} - self.cleanup_callbacks = [] + self.tasks: list[asyncio.Task[None]] = [] + self.current_rollers: dict[int, aiopulse.Roller] = {} + self.cleanup_callbacks: list[Callable[[], None]] = [] @property - def title(self): + def title(self) -> str: """Return the title of the hub shown in the integrations list.""" return f"{self.api.id} ({self.api.host})" @property - def host(self): + def host(self) -> str: """Return the host of this hub.""" - return self.config_entry.data["host"] + return self.config_entry.data["host"] # type: ignore[no-any-return] - async def async_setup(self, tries=0): + async def async_setup(self, tries: int = 0) -> bool: """Set up a hub based on host parameter.""" - host = self.host - - hub = aiopulse.Hub(host) - self.api = hub + self.api = hub = aiopulse.Hub(self.host) hub.callback_subscribe(self.async_notify_update) self.tasks.append(asyncio.create_task(hub.run())) @@ -46,7 +47,7 @@ async def async_setup(self, tries=0): LOGGER.debug("Hub setup complete") return True - async def async_reset(self): + async def async_reset(self) -> bool: """Reset this hub to default state.""" for cleanup_callback in self.cleanup_callbacks: @@ -66,7 +67,7 @@ async def async_reset(self): return True - async def async_notify_update(self, update_type): + async def async_notify_update(self, update_type: aiopulse.UpdateType) -> None: """Evaluate entities when hub reports that update has occurred.""" LOGGER.debug("Hub {update_type.name} updated") diff --git a/homeassistant/components/acmeda/manifest.json b/homeassistant/components/acmeda/manifest.json index 94dcf3325ca7b8..a8b3c7c829fc62 100644 --- a/homeassistant/components/acmeda/manifest.json +++ b/homeassistant/components/acmeda/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/acmeda", "iot_class": "local_push", "loggers": ["aiopulse"], - "requirements": ["aiopulse==0.4.3"] + "requirements": ["aiopulse==0.4.4"] } diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index e8ccb30ada431c..20d0929f3414cd 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( current: set[int] = set() @callback - def async_add_acmeda_sensors(): + def async_add_acmeda_sensors() -> None: async_add_acmeda_entities( hass, AcmedaBattery, config_entry, current, async_add_entities ) @@ -48,4 +48,4 @@ class AcmedaBattery(AcmedaBase, SensorEntity): @property def native_value(self) -> float | int | None: """Return the state of the device.""" - return self.roller.battery + return self.roller.battery # type: ignore[no-any-return] diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py index 511fb746216bba..cf60d40631c1ed 100644 --- a/homeassistant/components/adax/__init__.py +++ b/homeassistant/components/adax/__init__.py @@ -24,7 +24,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # convert title and unique_id to string if config_entry.version == 1: if isinstance(config_entry.unique_id, int): - hass.config_entries.async_update_entry( + hass.config_entries.async_update_entry( # type: ignore[unreachable] config_entry, unique_id=str(config_entry.unique_id), title=str(config_entry.title), diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 7587bfc0799cac..34812f9e449b79 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -137,7 +137,7 @@ class LocalAdaxDevice(ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, adax_data_handler, unique_id): + def __init__(self, adax_data_handler: AdaxLocal, unique_id: str) -> None: """Initialize the heater.""" self._adax_data_handler = adax_data_handler self._attr_unique_id = unique_id diff --git a/homeassistant/components/adax/config_flow.py b/homeassistant/components/adax/config_flow.py index b9e8ef1abcac15..b614c968d488ba 100644 --- a/homeassistant/components/adax/config_flow.py +++ b/homeassistant/components/adax/config_flow.py @@ -36,7 +36,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" data_schema = vol.Schema( { @@ -59,7 +61,9 @@ async def async_step_user(self, user_input=None): return await self.async_step_local() return await self.async_step_cloud() - async def async_step_local(self, user_input=None): + async def async_step_local( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the local step.""" data_schema = vol.Schema( {vol.Required(WIFI_SSID): str, vol.Required(WIFI_PSWD): str} diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index 65cffc509d57ce..2742180333b869 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 24e1283e9df80f..52add51a6635e7 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 9f1c0a5b0fe07b..c8ec5023533da5 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -22,20 +22,13 @@ PARALLEL_UPDATES = 4 -@dataclass -class AdGuardHomeEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, 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 e34a7c88229926..5b6a5a546f712c 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 1020e8690f10a5..4b6fe06cdab946 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -21,22 +21,15 @@ PARALLEL_UPDATES = 1 -@dataclass -class AdGuardHomeSwitchEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, 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/__init__.py b/homeassistant/components/advantage_air/__init__.py index 4dbc2edad8dfa5..1383ea7c05461b 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -8,6 +8,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ADVANTAGE_AIR_RETRY, DOMAIN @@ -26,6 +27,7 @@ ] _LOGGER = logging.getLogger(__name__) +REQUEST_REFRESH_DELAY = 0.5 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -51,6 +53,9 @@ async def async_get(): name="Advantage Air", update_method=async_get, update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL), + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 8244472f2b4ba4..a488ba8b362ed7 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -21,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ADVANTAGE_AIR_AUTOFAN_ENABLED, ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, @@ -39,16 +40,6 @@ } HASS_HVAC_MODES = {v: k for k, v in ADVANTAGE_AIR_HVAC_MODES.items()} -ADVANTAGE_AIR_FAN_MODES = { - "autoAA": FAN_AUTO, - "low": FAN_LOW, - "medium": FAN_MEDIUM, - "high": FAN_HIGH, -} -HASS_FAN_MODES = {v: k for k, v in ADVANTAGE_AIR_FAN_MODES.items()} -FAN_SPEEDS = {FAN_LOW: 30, FAN_MEDIUM: 60, FAN_HIGH: 100} - -ADVANTAGE_AIR_AUTOFAN = "aaAutoFanModeEnabled" ADVANTAGE_AIR_MYZONE = "MyZone" ADVANTAGE_AIR_MYAUTO = "MyAuto" ADVANTAGE_AIR_MYAUTO_ENABLED = "myAutoModeEnabled" @@ -56,6 +47,7 @@ ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled" ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp" ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp" +ADVANTAGE_AIR_MYFAN = "autoAA" PARALLEL_UPDATES = 0 @@ -85,27 +77,25 @@ async def async_setup_entry( class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """AdvantageAir AC unit.""" - _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 _attr_name = None - _attr_hvac_modes = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, - ] - - _attr_supported_features = ClimateEntityFeature.FAN_MODE - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" super().__init__(instance, ac_key) + self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + ] # Set supported features and HVAC modes based on current operating mode if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED): # MyAuto @@ -118,10 +108,6 @@ def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: # MyZone self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - # Add "ezfan" mode if supported - 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.""" @@ -151,7 +137,7 @@ def hvac_mode(self) -> HVACMode | None: @property def fan_mode(self) -> str | None: """Return the current fan modes.""" - return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) + return FAN_AUTO if self._ac["fan"] == ADVANTAGE_AIR_MYFAN else self._ac["fan"] @property def target_temperature_high(self) -> float | None: @@ -189,7 +175,11 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_fan_mode(self, fan_mode: str) -> None: """Set the Fan Mode.""" - await self.async_update_ac({"fan": HASS_FAN_MODES.get(fan_mode)}) + if fan_mode == FAN_AUTO and self._ac.get(ADVANTAGE_AIR_AUTOFAN_ENABLED): + mode = ADVANTAGE_AIR_MYFAN + else: + mode = fan_mode + await self.async_update_ac({"fan": mode}) async def async_set_temperature(self, **kwargs: Any) -> None: """Set the Temperature.""" diff --git a/homeassistant/components/advantage_air/const.py b/homeassistant/components/advantage_air/const.py index 5c044481ca05bc..80ce9b6eaa178b 100644 --- a/homeassistant/components/advantage_air/const.py +++ b/homeassistant/components/advantage_air/const.py @@ -5,3 +5,4 @@ ADVANTAGE_AIR_STATE_CLOSE = "close" ADVANTAGE_AIR_STATE_ON = "on" ADVANTAGE_AIR_STATE_OFF = "off" +ADVANTAGE_AIR_AUTOFAN_ENABLED = "aaAutoFanModeEnabled" diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index 691db99769bc46..9079e69ae09c34 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -30,7 +30,7 @@ def update_handle_factory(self, func, *keys): async def update_handle(*values): try: if await func(*keys, *values): - await self.coordinator.async_refresh() + await self.coordinator.async_request_refresh() except ApiError as err: raise HomeAssistantError(err) from err diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 7234ca36305891..abc9b795d430b0 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -7,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ADVANTAGE_AIR_AUTOFAN_ENABLED, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN, @@ -29,6 +30,8 @@ async def async_setup_entry( for ac_key, ac_device in aircons.items(): if ac_device["info"]["freshAirStatus"] != "none": entities.append(AdvantageAirFreshAir(instance, ac_key)) + if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]: + entities.append(AdvantageAirMyFan(instance, ac_key)) if things := instance.coordinator.data.get("myThings"): for thing in things["things"].values(): if thing["channelDipState"] == 8: # 8 = Other relay @@ -62,6 +65,32 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_OFF}) +class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity): + """Representation of Advantage Air MyFan control.""" + + _attr_icon = "mdi:fan-auto" + _attr_name = "MyFan" + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + """Initialize an Advantage Air MyFan control.""" + super().__init__(instance, ac_key) + self._attr_unique_id += "-myfan" + + @property + def is_on(self) -> bool: + """Return the MyFan status.""" + return self._ac[ADVANTAGE_AIR_AUTOFAN_ENABLED] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn MyFan on.""" + await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn MyFan off.""" + await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: False}) + + class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity): """Representation of Advantage Air Thing.""" diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index dbf3df823e333b..a58faaf6f6b90d 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -1,6 +1,8 @@ """Config flow for AEMET OpenData.""" from __future__ import annotations +from typing import Any + from aemet_opendata.exceptions import AuthError from aemet_opendata.interface import AEMET, ConnectionOptions import voluptuous as vol @@ -8,6 +10,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -29,7 +32,9 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for AEMET OpenData.""" - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index 544931b50b596d..2bc30860803445 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.6"] + "requirements": ["AEMET-OpenData==0.4.7"] } diff --git a/homeassistant/components/aep_ohio/__init__.py b/homeassistant/components/aep_ohio/__init__.py new file mode 100644 index 00000000000000..a602f1d794ac56 --- /dev/null +++ b/homeassistant/components/aep_ohio/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: AEP Ohio.""" diff --git a/homeassistant/components/aep_ohio/manifest.json b/homeassistant/components/aep_ohio/manifest.json new file mode 100644 index 00000000000000..9b85e537fc8888 --- /dev/null +++ b/homeassistant/components/aep_ohio/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "aep_ohio", + "name": "AEP Ohio", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/aep_texas/__init__.py b/homeassistant/components/aep_texas/__init__.py new file mode 100644 index 00000000000000..c8ff9829e226d6 --- /dev/null +++ b/homeassistant/components/aep_texas/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: AEP Texas.""" diff --git a/homeassistant/components/aep_texas/manifest.json b/homeassistant/components/aep_texas/manifest.json new file mode 100644 index 00000000000000..5de0e0ffd77fd6 --- /dev/null +++ b/homeassistant/components/aep_texas/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "aep_texas", + "name": "AEP Texas", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/aftership/config_flow.py b/homeassistant/components/aftership/config_flow.py index 3da6ac9e3d5a92..9457809150187a 100644 --- a/homeassistant/components/aftership/config_flow.py +++ b/homeassistant/components/aftership/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN -from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -51,25 +51,6 @@ async def async_step_user( async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Import configuration from yaml.""" - try: - self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]}) - except AbortFlow as err: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_yaml_import_issue_already_configured", - breaks_in_ha_version="2024.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_already_configured", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "AfterShip", - }, - ) - raise err - async_create_issue( self.hass, HOMEASSISTANT_DOMAIN, @@ -84,6 +65,8 @@ async def async_step_import(self, config: dict[str, Any]) -> FlowResult: "integration_title": "AfterShip", }, ) + + self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]}) return self.async_create_entry( title=config.get(CONF_NAME, "AfterShip"), data={CONF_API_KEY: config[CONF_API_KEY]}, diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json index b49c19976a6354..ace8eb6d2d3a0b 100644 --- a/homeassistant/components/aftership/strings.json +++ b/homeassistant/components/aftership/strings.json @@ -49,10 +49,6 @@ } }, "issues": { - "deprecated_yaml_import_issue_already_configured": { - "title": "The {integration_title} YAML configuration import failed", - "description": "Configuring {integration_title} using YAML is being removed but the YAML configuration was already imported.\n\nRemove the YAML configuration and restart Home Assistant." - }, "deprecated_yaml_import_issue_cannot_connect": { "title": "The {integration_title} YAML configuration import failed", "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." diff --git a/homeassistant/components/agent_dvr/config_flow.py b/homeassistant/components/agent_dvr/config_flow.py index 8da3a497ceb02a..9143d40352f8a1 100644 --- a/homeassistant/components/agent_dvr/config_flow.py +++ b/homeassistant/components/agent_dvr/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure Agent devices.""" from contextlib import suppress +from typing import Any from agent import AgentConnectionError, AgentError from agent.a import Agent @@ -7,6 +8,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, SERVER_URL @@ -18,11 +20,9 @@ class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle an Agent config flow.""" - def __init__(self): - """Initialize the Agent config flow.""" - self.device_config = {} - - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle an Agent config flow.""" errors = {} @@ -49,13 +49,15 @@ async def async_step_user(self, user_input=None): } ) - self.device_config = { + device_config = { CONF_HOST: host, CONF_PORT: port, SERVER_URL: server_origin, } - return await self._create_entry(agent_client.name) + return self.async_create_entry( + title=agent_client.name, data=device_config + ) errors["base"] = "cannot_connect" @@ -66,11 +68,6 @@ async def async_step_user(self, user_input=None): return self.async_show_form( step_id="user", - description_placeholders=self.device_config, data_schema=vol.Schema(data), errors=errors, ) - - async def _create_entry(self, server_name): - """Create entry for device.""" - return self.async_create_entry(title=server_name, data=self.device_config) diff --git a/homeassistant/components/agent_dvr/strings.json b/homeassistant/components/agent_dvr/strings.json index 77167b8294bd65..cbfc2e87a4db26 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/airly/sensor.py b/homeassistant/components/airly/sensor.py index 864c36f171a2f5..6105b277088d6d 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -56,7 +56,7 @@ PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class AirlySensorEntityDescription(SensorEntityDescription): """Class describing Airly sensor entities.""" diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index d72d145f7dec62..a6fa7aa5088d76 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -6,8 +6,17 @@ from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError import voluptuous as vol -from homeassistant import config_entries, core, data_entry_flow, exceptions +from homeassistant import core +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, + OptionsFlowWithConfigEntry, +) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +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 import homeassistant.helpers.config_validation as cv @@ -16,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -46,12 +55,14 @@ async def validate_input(hass: core.HomeAssistant, data): return True -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class AirNowConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for AirNow.""" VERSION = 2 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: @@ -108,18 +119,18 @@ async def async_step_user(self, user_input=None): @staticmethod @core.callback def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: + config_entry: ConfigEntry, + ) -> OptionsFlow: """Return the options flow.""" - return OptionsFlowHandler(config_entry) + return AirNowOptionsFlowHandler(config_entry) -class OptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): +class AirNowOptionsFlowHandler(OptionsFlowWithConfigEntry): """Handle an options flow for AirNow.""" async def async_step_init( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(data=user_input) @@ -141,13 +152,13 @@ async def async_step_init( ) -class CannotConnect(exceptions.HomeAssistantError): +class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" -class InvalidAuth(exceptions.HomeAssistantError): +class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" -class InvalidLocation(exceptions.HomeAssistantError): +class InvalidLocation(HomeAssistantError): """Error to indicate the location is invalid.""" diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index e89afc2f7ce071..4bdaadff0da3c0 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -1,11 +1,15 @@ """DataUpdateCoordinator for the AirNow integration.""" +from datetime import timedelta import logging +from typing import Any +from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from pyairnow import WebServiceAPI from pyairnow.conv import aqi_to_concentration from pyairnow.errors import AirNowError +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -31,12 +35,19 @@ _LOGGER = logging.getLogger(__name__) -class AirNowDataUpdateCoordinator(DataUpdateCoordinator): +class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The AirNow update coordinator.""" def __init__( - self, hass, session, api_key, latitude, longitude, distance, update_interval - ): + self, + hass: HomeAssistant, + session: ClientSession, + api_key: str, + latitude: float, + longitude: float, + distance: int, + update_interval: timedelta, + ) -> None: """Initialize.""" self.latitude = latitude self.longitude = longitude @@ -46,7 +57,7 @@ def __init__( super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" data = {} try: diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index c6ab27a8497cbf..9c154dc0712351 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -51,7 +51,7 @@ ATTR_STATION = "reporting_station" -@dataclass +@dataclass(frozen=True) class AirNowEntityDescriptionMixin: """Mixin for required keys.""" @@ -59,7 +59,7 @@ class AirNowEntityDescriptionMixin: extra_state_attributes_fn: Callable[[Any], dict[str, str]] | None -@dataclass +@dataclass(frozen=True) class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMixin): """Describes Airnow sensor entity.""" diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 41eda912e9829c..33d76ec75bc7b7 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -4,7 +4,7 @@ import logging from typing import Any -from aioairq import AirQ, InvalidAuth, InvalidInput +from aioairq import AirQ, InvalidAuth from aiohttp.client_exceptions import ClientConnectionError import voluptuous as vol @@ -42,44 +42,32 @@ async def async_step_user( errors: dict[str, str] = {} session = async_get_clientsession(self.hass) + airq = AirQ(user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD], session) try: - airq = AirQ(user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD], session) - except InvalidInput: + await airq.validate() + except ClientConnectionError: _LOGGER.debug( - "%s does not appear to be a valid IP address or mDNS name", + ( + "Failed to connect to device %s. Check the IP address / device" + " ID as well as whether the device is connected to power and" + " the WiFi" + ), user_input[CONF_IP_ADDRESS], ) - errors["base"] = "invalid_input" + errors["base"] = "cannot_connect" + except InvalidAuth: + _LOGGER.debug( + "Incorrect password for device %s", user_input[CONF_IP_ADDRESS] + ) + errors["base"] = "invalid_auth" else: - try: - await airq.validate() - except ClientConnectionError: - _LOGGER.debug( - ( - "Failed to connect to device %s. Check the IP address / device" - " ID as well as whether the device is connected to power and" - " the WiFi" - ), - user_input[CONF_IP_ADDRESS], - ) - errors["base"] = "cannot_connect" - except InvalidAuth: - _LOGGER.debug( - "Incorrect password for device %s", user_input[CONF_IP_ADDRESS] - ) - errors["base"] = "invalid_auth" - else: - _LOGGER.debug( - "Successfully connected to %s", user_input[CONF_IP_ADDRESS] - ) + _LOGGER.debug("Successfully connected to %s", user_input[CONF_IP_ADDRESS]) - device_info = await airq.fetch_device_info() - await self.async_set_unique_id(device_info["id"]) - self._abort_if_unique_id_configured() + device_info = await airq.fetch_device_info() + await self.async_set_unique_id(device_info["id"]) + self._abort_if_unique_id_configured() - return self.async_create_entry( - title=device_info["name"], data=user_input - ) + return self.async_create_entry(title=device_info["name"], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 82719515cbfb02..d1a2340b4bcf4d 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -3,7 +3,6 @@ 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 2d0d9d199dfcc3..6f49303bc6cf63 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -13,7 +13,7 @@ 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 @@ async def _async_update_data(self) -> dict: 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() # type: ignore[no-any-return] diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 97fb70c1b05365..2b23928aba87d7 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.2"] } diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index 9974307b4cd3ad..f1fdfb289dd222 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -37,14 +37,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class AirQEntityDescriptionMixin: """Class for keys required by AirQ entity.""" value: Callable[[dict], float | int | None] -@dataclass +@dataclass(frozen=True) class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin): """Describes AirQ sensor entity.""" diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py index 423e890a855aed..a5b962d1bf7d5f 100644 --- a/homeassistant/components/airthings/__init__.py +++ b/homeassistant/components/airthings/__init__.py @@ -4,21 +4,23 @@ from datetime import timedelta import logging -from airthings import Airthings, AirthingsError +from airthings import Airthings, AirthingsDevice, AirthingsError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_ID, CONF_SECRET, DOMAIN +from .const import CONF_SECRET, DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=6) +AirthingsDataCoordinatorType = DataUpdateCoordinator[dict[str, AirthingsDevice]] + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Airthings from a config entry.""" @@ -30,10 +32,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_get_clientsession(hass), ) - async def _update_method(): + async def _update_method() -> dict[str, AirthingsDevice]: """Get the latest data from Airthings.""" try: - return await airthings.update_devices() + return await airthings.update_devices() # type: ignore[no-any-return] except AirthingsError as err: raise UpdateFailed(f"Unable to fetch data: {err}") from err diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py index f07f7164f2bc8c..62f66213a0f2f7 100644 --- a/homeassistant/components/airthings/config_flow.py +++ b/homeassistant/components/airthings/config_flow.py @@ -8,10 +8,11 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_ID from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_ID, CONF_SECRET, DOMAIN +from .const import CONF_SECRET, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/airthings/const.py b/homeassistant/components/airthings/const.py index 70de549141b32c..5f846fbb31dd62 100644 --- a/homeassistant/components/airthings/const.py +++ b/homeassistant/components/airthings/const.py @@ -2,5 +2,4 @@ DOMAIN = "airthings" -CONF_ID = "id" CONF_SECRET = "secret" diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index cd4e9d52f6b2cc..3802a735a99cd2 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -24,11 +24,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import AirthingsDataCoordinatorType from .const import DOMAIN SENSORS: dict[str, SensorEntityDescription] = { @@ -108,7 +106,7 @@ async def async_setup_entry( ) -> None: """Set up the Airthings sensor.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: AirthingsDataCoordinatorType = hass.data[DOMAIN][entry.entry_id] entities = [ AirthingsHeaterEnergySensor( coordinator, @@ -122,7 +120,9 @@ async def async_setup_entry( async_add_entities(entities) -class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): +class AirthingsHeaterEnergySensor( + CoordinatorEntity[AirthingsDataCoordinatorType], SensorEntity +): """Representation of a Airthings Sensor device.""" _attr_state_class = SensorStateClass.MEASUREMENT @@ -130,7 +130,7 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: AirthingsDataCoordinatorType, airthings_device: AirthingsDevice, entity_description: SensorEntityDescription, ) -> None: @@ -155,4 +155,4 @@ def __init__( @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.coordinator.data[self._id].sensors[self.entity_description.key] + return self.coordinator.data[self._id].sensors[self.entity_description.key] # type: ignore[no-any-return] diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py index d7e6bddbcd4e10..1d62442f14de1d 100644 --- a/homeassistant/components/airthings_ble/__init__.py +++ b/homeassistant/components/airthings_ble/__init__.py @@ -4,7 +4,8 @@ from datetime import timedelta import logging -from airthings_ble import AirthingsBluetoothDeviceData +from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from bleak_retry_connector import close_stale_connections_by_address from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -30,6 +31,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: is_metric = hass.config.units is METRIC_SYSTEM assert address is not None + await close_stale_connections_by_address(address) + ble_device = bluetooth.async_ble_device_from_address(hass, address) if not ble_device: @@ -37,13 +40,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find Airthings device with address {address}" ) - async def _async_update_method(): + async def _async_update_method() -> AirthingsDevice: """Get data from Airthings BLE.""" ble_device = bluetooth.async_ble_device_from_address(hass, address) airthings = AirthingsBluetoothDeviceData(_LOGGER, elevation, is_metric) try: - data = await airthings.update_device(ble_device) + data = await airthings.update_device(ble_device) # type: ignore[arg-type] except Exception as err: raise UpdateFailed(f"Unable to fetch data: {err}") from err diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index aaeb91cf30bc1a..c4797713bb8a5a 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -1,6 +1,7 @@ """Support for airthings ble sensors.""" from __future__ import annotations +import dataclasses import logging from airthings_ble import AirthingsDevice @@ -167,10 +168,13 @@ async def async_setup_entry( # we need to change some units sensors_mapping = SENSORS_MAPPING_TEMPLATE.copy() if not is_metric: - for val in sensors_mapping.values(): + for key, val in sensors_mapping.items(): if val.native_unit_of_measurement is not VOLUME_BECQUEREL: continue - val.native_unit_of_measurement = VOLUME_PICOCURIE + sensors_mapping[key] = dataclasses.replace( + val, + native_unit_of_measurement=VOLUME_PICOCURIE, + ) entities = [] _LOGGER.debug("got sensors: %s", coordinator.data.sensors) diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json index 240b3e0007c39a..04c2e54cc7e7f1 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/__init__.py b/homeassistant/components/airvisual/__init__.py index e07400f27640e8..1d5babee6d72f0 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_COUNTRY, CONF_IP_ADDRESS, CONF_LATITUDE, CONF_LONGITUDE, @@ -44,7 +45,6 @@ from .const import ( CONF_CITY, - CONF_COUNTRY, CONF_GEOGRAPHIES, CONF_INTEGRATION_TYPE, DOMAIN, diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index 893726fc0223d0..23a26e2cca6c8e 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_SHOW_ON_MAP, @@ -35,7 +36,6 @@ from . import async_get_geography_id from .const import ( CONF_CITY, - CONF_COUNTRY, CONF_INTEGRATION_TYPE, DOMAIN, INTEGRATION_TYPE_GEOGRAPHY_COORDS, diff --git a/homeassistant/components/airvisual/const.py b/homeassistant/components/airvisual/const.py index 8e2c08eb8967d0..0afa7d32d41752 100644 --- a/homeassistant/components/airvisual/const.py +++ b/homeassistant/components/airvisual/const.py @@ -9,6 +9,5 @@ INTEGRATION_TYPE_NODE_PRO = "AirVisual Node/Pro" CONF_CITY = "city" -CONF_COUNTRY = "country" CONF_GEOGRAPHIES = "geographies" CONF_INTEGRATION_TYPE = "integration_type" diff --git a/homeassistant/components/airvisual/diagnostics.py b/homeassistant/components/airvisual/diagnostics.py index c273dbe7a55a89..05e716367bb01f 100644 --- a/homeassistant/components/airvisual/diagnostics.py +++ b/homeassistant/components/airvisual/diagnostics.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_STATE, @@ -15,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_CITY, CONF_COUNTRY, DOMAIN +from .const import CONF_CITY, DOMAIN CONF_COORDINATES = "coordinates" CONF_TITLE = "title" diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 1f0c5aa1baa97c..ab80e154903e7f 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -15,6 +15,7 @@ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_SHOW_ON_MAP, @@ -25,7 +26,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import AirVisualEntity -from .const import CONF_CITY, CONF_COUNTRY, DOMAIN +from .const import CONF_CITY, DOMAIN ATTR_CITY = "city" ATTR_COUNTRY = "country" diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 188647b73384ea..6a8e32bc32c84c 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -26,7 +26,7 @@ from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class AirVisualProMeasurementKeyMixin: """Define an entity description mixin to include a measurement key.""" @@ -35,7 +35,7 @@ class AirVisualProMeasurementKeyMixin: ] -@dataclass +@dataclass(frozen=True) class AirVisualProMeasurementDescription( SensorEntityDescription, AirVisualProMeasurementKeyMixin ): diff --git a/homeassistant/components/airvisual_pro/strings.json b/homeassistant/components/airvisual_pro/strings.json index b5c68371fdf2cf..641fa8963da8ff 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 cee0bb19691dcb..488c2c9613249e 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -29,7 +29,7 @@ from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity -@dataclass +@dataclass(frozen=True) class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes airzone binary sensor entities.""" diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 22172255b9b1e8..f5a0e1b109e838 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -248,7 +248,6 @@ def _async_update_attrs(self) -> None: self._attr_hvac_mode = HVACMode.OFF self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN) - self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) if self.supported_features & ClimateEntityFeature.FAN_MODE: self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: @@ -258,3 +257,5 @@ def _async_update_attrs(self) -> None: self._attr_target_temperature_low = self.get_airzone_value( AZD_HEAT_TEMP_SET ) + else: + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index e9485f1b9d0a15..20b8a452324fae 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.6.9"] + "requirements": ["aioairzone==0.7.2"] } diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 78b4dee3b721a8..6f69d4454ee363 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -26,7 +26,7 @@ from .entity import AirzoneEntity, AirzoneZoneEntity -@dataclass +@dataclass(frozen=True) class AirzoneSelectDescriptionMixin: """Define an entity description mixin for select entities.""" @@ -34,7 +34,7 @@ class AirzoneSelectDescriptionMixin: options_dict: dict[str, int] -@dataclass +@dataclass(frozen=True) class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescriptionMixin): """Class to describe an Airzone select entity.""" diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 2a182b7b487a57..9f99e49f6501b7 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -34,7 +34,7 @@ ) -@dataclass +@dataclass(frozen=True) class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes Airzone Cloud binary sensor entities.""" diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index ab8e08835a3613..f8b740dc04de17 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.6"] + "requirements": ["aioairzone-cloud==0.3.8"] } diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 604ac61300d4bb..f4104a39365645 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -11,6 +11,7 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, PlatformNotReady +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,6 +34,34 @@ async def async_setup_entry( async_add_entities( (AladdinDevice(acc, door, config_entry) for door in doors), ) + remove_stale_devices(hass, config_entry, doors) + + +def remove_stale_devices( + hass: HomeAssistant, config_entry: ConfigEntry, devices: list[dict] +) -> None: + """Remove stale devices from device registry.""" + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + all_device_ids = {f"{door['device_id']}-{door['door_number']}" for door in devices} + + for device_entry in device_entries: + device_id: str | None = None + + for identifier in device_entry.identifiers: + if identifier[0] == DOMAIN: + device_id = identifier[1] + break + + if device_id is None or device_id not in all_device_ids: + # If device_id is None an invalid device entry was found for this config entry. + # If the device_id is not in existing device ids it's a stale device entry. + # Remove config entry from this device entry in either case. + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry.entry_id + ) class AladdinDevice(CoverEntity): diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 83f8e0167e868f..344c77dcb7310c 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], + "quality_scale": "platinum", "requirements": ["AIOAladdinConnect==0.1.58"] } diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index e3a1f2d443caca..0a264edc8c29a1 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -23,14 +23,14 @@ from .model import DoorDevice -@dataclass +@dataclass(frozen=True) class AccSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable -@dataclass +@dataclass(frozen=True) class AccSensorEntityDescription( SensorEntityDescription, AccSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index f3e02465c13a50..9c53f2b7fd0b2d 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -1,10 +1,10 @@ """Component to interface with an alarm control panel.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta +from functools import partial import logging -from typing import Any, Final, final +from typing import TYPE_CHECKING, Any, Final, final import voluptuous as vol @@ -23,26 +23,41 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from .const import ( # noqa: F401 + _DEPRECATED_FORMAT_NUMBER, + _DEPRECATED_FORMAT_TEXT, + _DEPRECATED_SUPPORT_ALARM_ARM_AWAY, + _DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + _DEPRECATED_SUPPORT_ALARM_ARM_HOME, + _DEPRECATED_SUPPORT_ALARM_ARM_NIGHT, + _DEPRECATED_SUPPORT_ALARM_ARM_VACATION, + _DEPRECATED_SUPPORT_ALARM_TRIGGER, ATTR_CHANGED_BY, ATTR_CODE_ARM_REQUIRED, DOMAIN, - FORMAT_NUMBER, - FORMAT_TEXT, - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, AlarmControlPanelEntityFeature, CodeFormat, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + +# As we import constants of the cost module here, we need to add the following +# functions to check for deprecated constants again +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + _LOGGER: Final = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=30) @@ -121,12 +136,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class AlarmControlPanelEntityDescription(EntityDescription): +class AlarmControlPanelEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes alarm control panel entities.""" -class AlarmControlPanelEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "code_format", + "changed_by", + "code_arm_required", + "supported_features", +} + + +class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """An abstract class for alarm control entities.""" entity_description: AlarmControlPanelEntityDescription @@ -137,17 +159,17 @@ class AlarmControlPanelEntity(Entity): AlarmControlPanelEntityFeature(0) ) - @property + @cached_property def code_format(self) -> CodeFormat | None: """Code format or None if no code is required.""" return self._attr_code_format - @property + @cached_property def changed_by(self) -> str | None: """Last change triggered by.""" return self._attr_changed_by - @property + @cached_property def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" return self._attr_code_arm_required @@ -208,10 +230,15 @@ async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" await self.hass.async_add_executor_job(self.alarm_arm_custom_bypass, code) - @property + @cached_property def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = AlarmControlPanelEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features @final @property diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index f14a1ce66e0743..90bbcba1314b5f 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -1,7 +1,14 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "alarm_control_panel" ATTR_CHANGED_BY: Final = "changed_by" @@ -15,10 +22,10 @@ class CodeFormat(StrEnum): NUMBER = "number" -# These constants are deprecated as of Home Assistant 2022.5 +# These constants are deprecated as of Home Assistant 2022.5, can be removed in 2025.1 # Please use the CodeFormat enum instead. -FORMAT_TEXT: Final = "text" -FORMAT_NUMBER: Final = "number" +_DEPRECATED_FORMAT_TEXT: Final = DeprecatedConstantEnum(CodeFormat.TEXT, "2025.1") +_DEPRECATED_FORMAT_NUMBER: Final = DeprecatedConstantEnum(CodeFormat.NUMBER, "2025.1") class AlarmControlPanelEntityFeature(IntFlag): @@ -34,12 +41,28 @@ class AlarmControlPanelEntityFeature(IntFlag): # These constants are deprecated as of Home Assistant 2022.5 # Please use the AlarmControlPanelEntityFeature enum instead. -SUPPORT_ALARM_ARM_HOME: Final = 1 -SUPPORT_ALARM_ARM_AWAY: Final = 2 -SUPPORT_ALARM_ARM_NIGHT: Final = 4 -SUPPORT_ALARM_TRIGGER: Final = 8 -SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = 16 -SUPPORT_ALARM_ARM_VACATION: Final = 32 +_DEPRECATED_SUPPORT_ALARM_ARM_HOME: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_HOME, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_AWAY: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_AWAY, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_NIGHT: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_NIGHT, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_TRIGGER: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.TRIGGER, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_VACATION: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_VACATION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) CONDITION_TRIGGERED: Final = "is_triggered" CONDITION_DISARMED: Final = "is_disarmed" diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index e453be88934a47..9c068bb33275eb 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -28,13 +28,7 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import ATTR_CODE_ARM_REQUIRED, DOMAIN -from .const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, -) +from .const import AlarmControlPanelEntityFeature ACTION_TYPES: Final[set[str]] = { "arm_away", @@ -82,16 +76,16 @@ async def async_get_actions( } # Add actions for each entity that belongs to this integration - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: actions.append({**base_action, CONF_TYPE: "arm_away"}) - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: actions.append({**base_action, CONF_TYPE: "arm_home"}) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: actions.append({**base_action, CONF_TYPE: "arm_night"}) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: actions.append({**base_action, CONF_TYPE: "arm_vacation"}) actions.append({**base_action, CONF_TYPE: "disarm"}) - if supported_features & SUPPORT_ALARM_TRIGGER: + if supported_features & AlarmControlPanelEntityFeature.TRIGGER: actions.append({**base_action, CONF_TYPE: "trigger"}) return actions diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index ee8cb57f568a84..e3c627d17a3032 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -39,11 +39,7 @@ CONDITION_ARMED_VACATION, CONDITION_DISARMED, CONDITION_TRIGGERED, - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, + AlarmControlPanelEntityFeature, ) CONDITION_TYPES: Final[set[str]] = { @@ -90,15 +86,15 @@ async def async_get_conditions( {**base_condition, CONF_TYPE: CONDITION_DISARMED}, {**base_condition, CONF_TYPE: CONDITION_TRIGGERED}, ] - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_HOME}) - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_AWAY}) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_NIGHT}) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_VACATION}) - if supported_features & SUPPORT_ALARM_ARM_CUSTOM_BYPASS: + if supported_features & AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS: conditions.append( {**base_condition, CONF_TYPE: CONDITION_ARMED_CUSTOM_BYPASS} ) diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index fc3850dce30ff0..e5141a1dfd539f 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -29,12 +29,7 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN -from .const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, -) +from .const import AlarmControlPanelEntityFeature BASIC_TRIGGER_TYPES: Final[set[str]] = {"triggered", "disarmed", "arming"} TRIGGER_TYPES: Final[set[str]] = BASIC_TRIGGER_TYPES | { @@ -82,28 +77,28 @@ async def async_get_triggers( } for trigger in BASIC_TRIGGER_TYPES ] - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: triggers.append( { **base_trigger, CONF_TYPE: "armed_home", } ) - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: triggers.append( { **base_trigger, CONF_TYPE: "armed_away", } ) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: triggers.append( { **base_trigger, CONF_TYPE: "armed_night", } ) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: triggers.append( { **base_trigger, diff --git a/homeassistant/components/alarm_control_panel/significant_change.py b/homeassistant/components/alarm_control_panel/significant_change.py new file mode 100644 index 00000000000000..bde6d15139343d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/significant_change.py @@ -0,0 +1,41 @@ +"""Helper to test significant Alarm Control Panel state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_CHANGED_BY, ATTR_CODE_ARM_REQUIRED + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_CHANGED_BY, + ATTR_CODE_ARM_REQUIRED, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + if changed_attrs: + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/alarmdecoder/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index 9a4b9ae109880f..1b2bcf083ba87c 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from adext import AdExt from alarmdecoder.devices import SerialDevice, SocketDevice @@ -12,8 +13,10 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_ALT_NIGHT_MODE, @@ -66,7 +69,9 @@ def async_get_options_flow( """Get the options flow for AlarmDecoder.""" return AlarmDecoderOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: self.protocol = user_input[CONF_PROTOCOL] @@ -83,7 +88,9 @@ async def async_step_user(self, user_input=None): ), ) - async def async_step_protocol(self, user_input=None): + async def async_step_protocol( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle AlarmDecoder protocol setup.""" errors = {} if user_input is not None: @@ -146,15 +153,18 @@ def test_connection(): class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): """Handle AlarmDecoder options.""" + selected_zone: str | None = None + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize AlarmDecoder options flow.""" self.arm_options = config_entry.options.get(OPTIONS_ARM, DEFAULT_ARM_OPTIONS) self.zone_options = config_entry.options.get( OPTIONS_ZONES, DEFAULT_ZONE_OPTIONS ) - self.selected_zone = None - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: if user_input[EDIT_KEY] == EDIT_SETTINGS: @@ -173,7 +183,9 @@ async def async_step_init(self, user_input=None): ), ) - async def async_step_arm_settings(self, user_input=None): + async def async_step_arm_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Arming options form.""" if user_input is not None: return self.async_create_entry( @@ -200,7 +212,9 @@ async def async_step_arm_settings(self, user_input=None): ), ) - async def async_step_zone_select(self, user_input=None): + async def async_step_zone_select( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Zone selection form.""" errors = _validate_zone_input(user_input) @@ -216,7 +230,9 @@ async def async_step_zone_select(self, user_input=None): errors=errors, ) - async def async_step_zone_details(self, user_input=None): + async def async_step_zone_details( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Zone details form.""" errors = _validate_zone_input(user_input) @@ -293,7 +309,7 @@ async def async_step_zone_details(self, user_input=None): ) -def _validate_zone_input(zone_input): +def _validate_zone_input(zone_input: dict[str, Any] | None) -> dict[str, str]: if not zone_input: return {} errors = {} @@ -327,7 +343,7 @@ def _validate_zone_input(zone_input): return errors -def _fix_input_types(zone_input): +def _fix_input_types(zone_input: dict[str, Any]) -> dict[str, Any]: """Convert necessary keys to int. Since ConfigFlow inputs of type int cannot default to an empty string, we collect the values below as @@ -341,7 +357,9 @@ def _fix_input_types(zone_input): return zone_input -def _device_already_added(current_entries, user_input, protocol): +def _device_already_added( + current_entries: list[ConfigEntry], user_input: dict[str, Any], protocol: str | None +) -> bool: """Determine if entry has already been added to HA.""" user_host = user_input.get(CONF_HOST) user_port = user_input.get(CONF_PORT) diff --git a/homeassistant/components/alarmdecoder/strings.json b/homeassistant/components/alarmdecoder/strings.json index d7ac882bb827d3..dd698201b0944e 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/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 219553b3563873..2a9637772b114a 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -36,6 +36,15 @@ CONF_SMART_HOME = "smart_home" DEFAULT_LOCALE = "en-US" +# Alexa Smart Home API send events gateway endpoints +# https://developer.amazon.com/en-US/docs/alexa/smarthome/send-events.html#endpoints +VALID_ENDPOINTS = [ + "https://api.amazonalexa.com/v3/events", + "https://api.eu.amazonalexa.com/v3/events", + "https://api.fe.amazonalexa.com/v3/events", +] + + ALEXA_ENTITY_SCHEMA = vol.Schema( { vol.Optional(CONF_DESCRIPTION): cv.string, @@ -46,7 +55,7 @@ SMART_HOME_SCHEMA = vol.Schema( { - vol.Optional(CONF_ENDPOINT): cv.string, + vol.Optional(CONF_ENDPOINT): vol.All(vol.Lower, vol.In(VALID_ENDPOINTS)), vol.Optional(CONF_CLIENT_ID): cv.string, vol.Optional(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_LOCALE, default=DEFAULT_LOCALE): vol.In( diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index cde90e127f32a6..502912ee8de855 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -19,6 +19,8 @@ number, timer, vacuum, + valve, + water_heater, ) from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, @@ -435,7 +437,8 @@ def get_property(self, name: str) -> Any: is_on = self.entity.state == vacuum.STATE_CLEANING elif self.entity.domain == timer.DOMAIN: is_on = self.entity.state != STATE_IDLE - + elif self.entity.domain == water_heater.DOMAIN: + is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN) else: is_on = self.entity.state != STATE_OFF @@ -857,16 +860,18 @@ def name(self) -> str: def inputs(self) -> list[dict[str, str]] | None: """Return the list of valid supported inputs.""" - source_list: list[str] = self.entity.attributes.get( + source_list: list[Any] = self.entity.attributes.get( media_player.ATTR_INPUT_SOURCE_LIST, [] ) return AlexaInputController.get_valid_inputs(source_list) @staticmethod - def get_valid_inputs(source_list: list[str]) -> list[dict[str, str]]: + def get_valid_inputs(source_list: list[Any]) -> list[dict[str, str]]: """Return list of supported inputs.""" input_list: list[dict[str, str]] = [] for source in source_list: + if not isinstance(source, str): + continue formatted_source = ( source.lower().replace("-", "").replace("_", "").replace(" ", "") ) @@ -936,6 +941,9 @@ def get_property(self, name: str) -> Any: if self.entity.domain == climate.DOMAIN: unit = self.hass.config.units.temperature_unit temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE) + elif self.entity.domain == water_heater.DOMAIN: + unit = self.hass.config.units.temperature_unit + temp = self.entity.attributes.get(water_heater.ATTR_CURRENT_TEMPERATURE) if temp is None or temp in (STATE_UNAVAILABLE, STATE_UNKNOWN): return None @@ -1106,6 +1114,8 @@ def properties_supported(self) -> list[dict[str, str]]: supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE: properties.append({"name": "targetSetpoint"}) + if supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE: + properties.append({"name": "targetSetpoint"}) if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: properties.append({"name": "lowerSetpoint"}) properties.append({"name": "upperSetpoint"}) @@ -1125,6 +1135,8 @@ def get_property(self, name: str) -> Any: return None if name == "thermostatMode": + if self.entity.domain == water_heater.DOMAIN: + return None preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE) mode: dict[str, str] | str | None @@ -1174,9 +1186,13 @@ def configuration(self) -> dict[str, Any] | None: ThermostatMode Values. ThermostatMode Value must be AUTO, COOL, HEAT, ECO, OFF, or CUSTOM. + Water heater devices do not return thermostat modes. """ + if self.entity.domain == water_heater.DOMAIN: + return None + supported_modes: list[str] = [] - hvac_modes = self.entity.attributes[climate.ATTR_HVAC_MODES] + hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES, []) for mode in hvac_modes: if thermostat_mode := API_THERMOSTAT_MODES.get(mode): supported_modes.append(thermostat_mode) @@ -1406,6 +1422,16 @@ def get_property(self, name: str) -> Any: if mode in self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []): return f"{humidifier.ATTR_MODE}.{mode}" + # Water heater operation mode + if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + operation_mode = self.entity.attributes.get( + water_heater.ATTR_OPERATION_MODE, None + ) + if operation_mode in self.entity.attributes.get( + water_heater.ATTR_OPERATION_LIST, [] + ): + return f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}" + # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": # Return state instead of position when using ModeController. @@ -1419,6 +1445,19 @@ def get_property(self, name: str) -> Any: ): return f"{cover.ATTR_POSITION}.{mode}" + # Valve position state + if self.instance == f"{valve.DOMAIN}.state": + # Return state instead of position when using ModeController. + state = self.entity.state + if state in ( + valve.STATE_OPEN, + valve.STATE_OPENING, + valve.STATE_CLOSED, + valve.STATE_CLOSING, + STATE_UNKNOWN, + ): + return f"state.{state}" + return None def configuration(self) -> dict[str, Any] | None: @@ -1476,6 +1515,26 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: ) return self._resource.serialize_capability_resources() + # Water heater operation modes + if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) + operation_modes = self.entity.attributes.get( + water_heater.ATTR_OPERATION_LIST, [] + ) + for operation_mode in operation_modes: + self._resource.add_mode( + f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}", + [operation_mode], + ) + # Devices with a single mode completely break Alexa discovery, + # add a fake preset (see issue #53832). + if len(operation_modes) == 1: + self._resource.add_mode( + f"{water_heater.ATTR_OPERATION_MODE}.{PRESET_MODE_NA}", + [PRESET_MODE_NA], + ) + return self._resource.serialize_capability_resources() + # Cover Position Resources if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": self._resource = AlexaModeResource( @@ -1495,6 +1554,32 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: ) return self._resource.serialize_capability_resources() + # Valve position resources + if self.instance == f"{valve.DOMAIN}.state": + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + self._resource = AlexaModeResource( + ["Preset", AlexaGlobalCatalog.SETTING_PRESET], False + ) + modes = 0 + if supported_features & valve.ValveEntityFeature.OPEN: + self._resource.add_mode( + f"state.{valve.STATE_OPEN}", + ["Open", AlexaGlobalCatalog.SETTING_PRESET], + ) + modes += 1 + if supported_features & valve.ValveEntityFeature.CLOSE: + self._resource.add_mode( + f"state.{valve.STATE_CLOSED}", + ["Closed", AlexaGlobalCatalog.SETTING_PRESET], + ) + modes += 1 + + # Alexa requiers at least 2 modes + if modes == 1: + self._resource.add_mode(f"state.{PRESET_MODE_NA}", [PRESET_MODE_NA]) + + return self._resource.serialize_capability_resources() + return {} def semantics(self) -> dict[str, Any] | None: @@ -1533,6 +1618,34 @@ def semantics(self) -> dict[str, Any] | None: return self._semantics.serialize_semantics() + # Valve Position + if self.instance == f"{valve.DOMAIN}.state": + close_labels = [AlexaSemantics.ACTION_CLOSE] + open_labels = [AlexaSemantics.ACTION_OPEN] + self._semantics = AlexaSemantics() + + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_CLOSED], + f"state.{valve.STATE_CLOSED}", + ) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_OPEN], + f"state.{valve.STATE_OPEN}", + ) + + self._semantics.add_action_to_directive( + close_labels, + "SetMode", + {"mode": f"state.{valve.STATE_CLOSED}"}, + ) + self._semantics.add_action_to_directive( + open_labels, + "SetMode", + {"mode": f"state.{valve.STATE_OPEN}"}, + ) + + return self._semantics.serialize_semantics() + return None @@ -1646,6 +1759,10 @@ def get_property(self, name: str) -> Any: ) return speed_index + # Valve Position + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + return self.entity.attributes.get(valve.ATTR_CURRENT_POSITION) + return None def configuration(self) -> dict[str, Any] | None: @@ -1769,6 +1886,17 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: return self._resource.serialize_capability_resources() + # Valve Position Resources + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + self._resource = AlexaPresetResource( + ["Opening", AlexaGlobalCatalog.SETTING_OPENING], + min_value=0, + max_value=100, + precision=1, + unit=AlexaGlobalCatalog.UNIT_PERCENT, + ) + return self._resource.serialize_capability_resources() + return {} def semantics(self) -> dict[str, Any] | None: @@ -1845,6 +1973,25 @@ def semantics(self) -> dict[str, Any] | None: ) return self._semantics.serialize_semantics() + # Valve Position + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + close_labels = [AlexaSemantics.ACTION_CLOSE] + open_labels = [AlexaSemantics.ACTION_OPEN] + self._semantics = AlexaSemantics() + + self._semantics.add_states_to_value([AlexaSemantics.STATES_CLOSED], value=0) + self._semantics.add_states_to_range( + [AlexaSemantics.STATES_OPEN], min_value=1, max_value=100 + ) + + self._semantics.add_action_to_directive( + close_labels, "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + open_labels, "SetRangeValue", {"rangeValue": 100} + ) + return self._semantics.serialize_semantics() + return None @@ -1918,6 +2065,10 @@ def get_property(self, name: str) -> Any: is_on = bool(self.entity.attributes.get(fan.ATTR_OSCILLATING)) return "ON" if is_on else "OFF" + # Stop Valve + if self.instance == f"{valve.DOMAIN}.stop": + return "OFF" + return None def capability_resources(self) -> dict[str, list[dict[str, Any]]]: @@ -1930,6 +2081,10 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: ) return self._resource.serialize_capability_resources() + if self.instance == f"{valve.DOMAIN}.stop": + self._resource = AlexaCapabilityResource(["Stop"]) + return self._resource.serialize_capability_resources() + return {} diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index da0bd8b36aaa9d..d0e265b8454de5 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -32,6 +32,8 @@ switch, timer, vacuum, + valve, + water_heater, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -248,6 +250,9 @@ class DisplayCategory: # Indicates a vacuum cleaner. VACUUM_CLEANER = "VACUUM_CLEANER" + # Indicates a water heater. + WATER_HEATER = "WATER_HEATER" + # Indicates a network-connected wearable device, such as an Apple Watch, # Fitbit, or Samsung Gear. WEARABLE = "WEARABLE" @@ -456,23 +461,46 @@ def interfaces(self) -> list[AlexaCapability]: @ENTITY_ADAPTERS.register(climate.DOMAIN) +@ENTITY_ADAPTERS.register(water_heater.DOMAIN) class ClimateCapabilities(AlexaEntity): """Class to represent Climate capabilities.""" def default_display_categories(self) -> list[str]: """Return the display categories for this entity.""" + if self.entity.domain == water_heater.DOMAIN: + return [DisplayCategory.WATER_HEATER] return [DisplayCategory.THERMOSTAT] def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" # If we support two modes, one being off, we allow turning on too. - if climate.HVACMode.OFF in self.entity.attributes.get( - climate.ATTR_HVAC_MODES, [] + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + self.entity.domain == climate.DOMAIN + and climate.HVACMode.OFF + in self.entity.attributes.get(climate.ATTR_HVAC_MODES, []) + or self.entity.domain == water_heater.DOMAIN + and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) ): yield AlexaPowerController(self.entity) - yield AlexaThermostatController(self.hass, self.entity) - yield AlexaTemperatureSensor(self.hass, self.entity) + if ( + self.entity.domain == climate.DOMAIN + or self.entity.domain == water_heater.DOMAIN + and ( + supported_features + & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ) + ): + yield AlexaThermostatController(self.hass, self.entity) + yield AlexaTemperatureSensor(self.hass, self.entity) + if self.entity.domain == water_heater.DOMAIN and ( + supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ): + yield AlexaModeController( + self.entity, + instance=f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}", + ) yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.entity) @@ -949,6 +977,31 @@ def interfaces(self) -> Generator[AlexaCapability, None, None]: yield Alexa(self.entity) +@ENTITY_ADAPTERS.register(valve.DOMAIN) +class ValveCapabilities(AlexaEntity): + """Class to represent Valve capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self) -> Generator[AlexaCapability, None, None]: + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & valve.ValveEntityFeature.SET_POSITION: + yield AlexaRangeController( + self.entity, instance=f"{valve.DOMAIN}.{valve.ATTR_POSITION}" + ) + elif supported & ( + valve.ValveEntityFeature.CLOSE | valve.ValveEntityFeature.OPEN + ): + yield AlexaModeController(self.entity, instance=f"{valve.DOMAIN}.state") + if supported & valve.ValveEntityFeature.STOP: + yield AlexaToggleController(self.entity, instance=f"{valve.DOMAIN}.stop") + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + @ENTITY_ADAPTERS.register(camera.DOMAIN) class CameraCapabilities(AlexaEntity): """Class to represent Camera capabilities.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index f99b0231e4d4be..5613da52db500c 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -22,6 +22,8 @@ number, timer, vacuum, + valve, + water_heater, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -80,6 +82,23 @@ _LOGGER = logging.getLogger(__name__) DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive" + +MIN_MAX_TEMP = { + climate.DOMAIN: { + "min_temp": climate.ATTR_MIN_TEMP, + "max_temp": climate.ATTR_MAX_TEMP, + }, + water_heater.DOMAIN: { + "min_temp": water_heater.ATTR_MIN_TEMP, + "max_temp": water_heater.ATTR_MAX_TEMP, + }, +} + +SERVICE_SET_TEMPERATURE = { + climate.DOMAIN: climate.SERVICE_SET_TEMPERATURE, + water_heater.DOMAIN: water_heater.SERVICE_SET_TEMPERATURE, +} + HANDLERS: Registry[ tuple[str, str], Callable[ @@ -804,8 +823,10 @@ async def async_api_set_target_temp( ) -> AlexaResponse: """Process a set target temperature request.""" entity = directive.entity - min_temp = entity.attributes[climate.ATTR_MIN_TEMP] - max_temp = entity.attributes[climate.ATTR_MAX_TEMP] + domain = entity.domain + + min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] + max_temp = entity.attributes["max_temp"] unit = hass.config.units.temperature_unit data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} @@ -849,9 +870,11 @@ async def async_api_set_target_temp( } ) + service = SERVICE_SET_TEMPERATURE[domain] + await hass.services.async_call( entity.domain, - climate.SERVICE_SET_TEMPERATURE, + service, data, blocking=False, context=context, @@ -867,11 +890,12 @@ async def async_api_adjust_target_temp( directive: AlexaDirective, context: ha.Context, ) -> AlexaResponse: - """Process an adjust target temperature request.""" + """Process an adjust target temperature request for climates and water heaters.""" data: dict[str, Any] entity = directive.entity - min_temp = entity.attributes[climate.ATTR_MIN_TEMP] - max_temp = entity.attributes[climate.ATTR_MAX_TEMP] + domain = entity.domain + min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] + max_temp = entity.attributes[MIN_MAX_TEMP[domain]["max_temp"]] unit = hass.config.units.temperature_unit temp_delta = temperature_from_object( @@ -932,9 +956,11 @@ async def async_api_adjust_target_temp( } ) + service = SERVICE_SET_TEMPERATURE[domain] + await hass.services.async_call( entity.domain, - climate.SERVICE_SET_TEMPERATURE, + service, data, blocking=False, context=context, @@ -1163,6 +1189,23 @@ async def async_api_set_mode( msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'" raise AlexaInvalidValueError(msg) + # Water heater operation mode + elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + operation_mode = mode.split(".")[1] + operation_modes: list[str] | None = entity.attributes.get( + water_heater.ATTR_OPERATION_LIST + ) + if ( + operation_mode != PRESET_MODE_NA + and operation_modes + and operation_mode in operation_modes + ): + service = water_heater.SERVICE_SET_OPERATION_MODE + data[water_heater.ATTR_OPERATION_MODE] = operation_mode + else: + msg = f"Entity '{entity.entity_id}' does not support Operation mode '{operation_mode}'" + raise AlexaInvalidValueError(msg) + # Cover Position elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": position = mode.split(".")[1] @@ -1174,6 +1217,15 @@ async def async_api_set_mode( elif position == "custom": service = cover.SERVICE_STOP_COVER + # Valve position state + elif instance == f"{valve.DOMAIN}.state": + position = mode.split(".")[1] + + if position == valve.STATE_CLOSED: + service = valve.SERVICE_CLOSE_VALVE + elif position == valve.STATE_OPEN: + service = valve.SERVICE_OPEN_VALVE + if not service: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) @@ -1224,16 +1276,23 @@ async def async_api_toggle_on( instance = directive.instance domain = entity.domain + data: dict[str, Any] + # Fan Oscillating - if instance != f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data = { + ATTR_ENTITY_ID: entity.entity_id, + fan.ATTR_OSCILLATING: True, + } + elif instance == f"{valve.DOMAIN}.stop": + service = valve.SERVICE_STOP_VALVE + data = { + ATTR_ENTITY_ID: entity.entity_id, + } + else: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) - service = fan.SERVICE_OSCILLATE - data: dict[str, Any] = { - ATTR_ENTITY_ID: entity.entity_id, - fan.ATTR_OSCILLATING: True, - } - await hass.services.async_call( domain, service, data, blocking=False, context=context ) @@ -1304,13 +1363,14 @@ async def async_api_set_range( service = None data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} range_value = directive.payload["rangeValue"] + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Cover Position if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_value = int(range_value) - if range_value == 0: + if supported & cover.CoverEntityFeature.CLOSE and range_value == 0: service = cover.SERVICE_CLOSE_COVER - elif range_value == 100: + elif supported & cover.CoverEntityFeature.OPEN and range_value == 100: service = cover.SERVICE_OPEN_COVER else: service = cover.SERVICE_SET_COVER_POSITION @@ -1319,9 +1379,9 @@ async def async_api_set_range( # Cover Tilt elif instance == f"{cover.DOMAIN}.tilt": range_value = int(range_value) - if range_value == 0: + if supported & cover.CoverEntityFeature.CLOSE_TILT and range_value == 0: service = cover.SERVICE_CLOSE_COVER_TILT - elif range_value == 100: + elif supported & cover.CoverEntityFeature.OPEN_TILT and range_value == 100: service = cover.SERVICE_OPEN_COVER_TILT else: service = cover.SERVICE_SET_COVER_TILT_POSITION @@ -1332,13 +1392,11 @@ async def async_api_set_range( range_value = int(range_value) if range_value == 0: service = fan.SERVICE_TURN_OFF + elif supported & fan.FanEntityFeature.SET_SPEED: + service = fan.SERVICE_SET_PERCENTAGE + data[fan.ATTR_PERCENTAGE] = range_value else: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported and fan.FanEntityFeature.SET_SPEED: - service = fan.SERVICE_SET_PERCENTAGE - data[fan.ATTR_PERCENTAGE] = range_value - else: - service = fan.SERVICE_TURN_ON + service = fan.SERVICE_TURN_ON # Humidifier target humidity elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": @@ -1376,6 +1434,17 @@ async def async_api_set_range( data[vacuum.ATTR_FAN_SPEED] = speed + # Valve Position + elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + range_value = int(range_value) + if supported & valve.ValveEntityFeature.CLOSE and range_value == 0: + service = valve.SERVICE_CLOSE_VALVE + elif supported & valve.ValveEntityFeature.OPEN and range_value == 100: + service = valve.SERVICE_OPEN_VALVE + else: + service = valve.SERVICE_SET_VALVE_POSITION + data[valve.ATTR_POSITION] = range_value + else: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) @@ -1521,6 +1590,21 @@ async def async_api_adjust_range( ) data[vacuum.ATTR_FAN_SPEED] = response_value = speed + # Valve Position + elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) + service = valve.SERVICE_SET_VALVE_POSITION + if not (current := entity.attributes.get(valve.ATTR_POSITION)): + msg = f"Unable to determine {entity.entity_id} current position" + raise AlexaInvalidValueError(msg) + position = response_value = min(100, max(0, range_delta + current)) + if position == 100: + service = valve.SERVICE_OPEN_VALVE + elif position == 0: + service = valve.SERVICE_CLOSE_VALVE + else: + data[valve.ATTR_POSITION] = position + else: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 02c6958e0da871..52427065f68bb1 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -74,9 +74,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Alpha Vantage sensor.""" - api_key = config[CONF_API_KEY] - symbols = config.get(CONF_SYMBOLS, []) - conversions = config.get(CONF_FOREIGN_EXCHANGE, []) + api_key: str = config[CONF_API_KEY] + symbols: list[dict[str, str]] = config.get(CONF_SYMBOLS, []) + conversions: list[dict[str, str]] = config.get(CONF_FOREIGN_EXCHANGE, []) if not symbols and not conversions: msg = "No symbols or currencies configured." @@ -120,7 +120,7 @@ class AlphaVantageSensor(SensorEntity): _attr_attribution = ATTRIBUTION - def __init__(self, timeseries, symbol): + def __init__(self, timeseries: TimeSeries, symbol: dict[str, str]) -> None: """Initialize the sensor.""" self._symbol = symbol[CONF_SYMBOL] self._attr_name = symbol.get(CONF_NAME, self._symbol) @@ -154,7 +154,9 @@ class AlphaVantageForeignExchange(SensorEntity): _attr_attribution = ATTRIBUTION - def __init__(self, foreign_exchange, config): + def __init__( + self, foreign_exchange: ForeignExchange, config: dict[str, str] + ) -> None: """Initialize the sensor.""" self._foreign_exchange = foreign_exchange self._from_currency = config[CONF_FROM] diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py index 1931bcbd32cdc7..25a6c2fe26741a 100644 --- a/homeassistant/components/amberelectric/binary_sensor.py +++ b/homeassistant/components/amberelectric/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from typing import Any from homeassistant.components.binary_sensor import ( @@ -45,14 +44,14 @@ def __init__( @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self.coordinator.data["grid"][self.entity_description.key] + return self.coordinator.data["grid"][self.entity_description.key] # type: ignore[no-any-return] class AmberPriceSpikeBinarySensor(AmberPriceGridSensor): """Sensor to show single grid binary values.""" @property - def icon(self): + def icon(self) -> str: """Return the sensor icon.""" status = self.coordinator.data["grid"]["price_spike"] return PRICE_SPIKE_ICONS[status] @@ -60,10 +59,10 @@ def icon(self): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self.coordinator.data["grid"]["price_spike"] == "spike" + return self.coordinator.data["grid"]["price_spike"] == "spike" # type: ignore[no-any-return] @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any]: """Return additional pieces of information about the price spike.""" spike_status = self.coordinator.data["grid"]["price_spike"] @@ -80,10 +79,10 @@ async def async_setup_entry( """Set up a config entry.""" coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities: list = [] price_spike_description = BinarySensorEntityDescription( key="price_spike", name=f"{entry.title} - Price Spike", ) - entities.append(AmberPriceSpikeBinarySensor(coordinator, price_spike_description)) - async_add_entities(entities) + async_add_entities( + [AmberPriceSpikeBinarySensor(coordinator, price_spike_description)] + ) diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py index 0258fdf4cb40bc..4011f442ee24c4 100644 --- a/homeassistant/components/amberelectric/config_flow.py +++ b/homeassistant/components/amberelectric/config_flow.py @@ -28,10 +28,10 @@ def __init__(self) -> None: def _fetch_sites(self, token: str) -> list[Site] | None: configuration = amberelectric.Configuration(access_token=token) - api = amber_api.AmberApi.create(configuration) + api: amber_api.AmberApi = amber_api.AmberApi.create(configuration) try: - sites = api.get_sites() + sites: list[Site] = api.get_sites() if len(sites) == 0: self._errors[CONF_API_TOKEN] = "no_site" return None diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index f3cda887150c2a..5f92e5a9117365 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -4,7 +4,6 @@ from homeassistant.const import Platform DOMAIN = "amberelectric" -CONF_API_TOKEN = "api_token" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" CONF_SITE_NMI = "site_nmi" diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 75cf3fd4360603..3e420be2f686ee 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -30,19 +30,19 @@ def is_forecast(interval: ActualInterval | CurrentInterval | ForecastInterval) - def is_general(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: """Return true if the supplied interval is on the general channel.""" - return interval.channel_type == ChannelType.GENERAL + return interval.channel_type == ChannelType.GENERAL # type: ignore[no-any-return] def is_controlled_load( interval: ActualInterval | CurrentInterval | ForecastInterval, ) -> bool: """Return true if the supplied interval is on the controlled load channel.""" - return interval.channel_type == ChannelType.CONTROLLED_LOAD + return interval.channel_type == ChannelType.CONTROLLED_LOAD # type: ignore[no-any-return] def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: """Return true if the supplied interval is on the feed in channel.""" - return interval.channel_type == ChannelType.FEED_IN + return interval.channel_type == ChannelType.FEED_IN # type: ignore[no-any-return] def normalize_descriptor(descriptor: Descriptor) -> str | None: diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 4a6d1a6ea18fcf..97ecc1036618fe 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -7,7 +7,6 @@ from __future__ import annotations -from collections.abc import Mapping from typing import Any from amberelectric.model.channel import ChannelType @@ -86,7 +85,7 @@ def native_value(self) -> float | None: return format_cents_to_dollars(interval.per_kwh) @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return additional pieces of information about the price.""" interval = self.coordinator.data[self.entity_description.key][self.channel_type] @@ -133,7 +132,7 @@ def native_value(self) -> float | None: return format_cents_to_dollars(interval.per_kwh) @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return additional pieces of information about the price.""" intervals = self.coordinator.data[self.entity_description.key].get( self.channel_type @@ -177,7 +176,7 @@ class AmberPriceDescriptorSensor(AmberSensor): @property def native_value(self) -> str | None: """Return the current price descriptor.""" - return self.coordinator.data[self.entity_description.key][self.channel_type] + return self.coordinator.data[self.entity_description.key][self.channel_type] # type: ignore[no-any-return] class AmberGridSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity): @@ -199,7 +198,7 @@ def __init__( @property def native_value(self) -> str | None: """Return the value of the sensor.""" - return self.coordinator.data["grid"][self.entity_description.key] + return self.coordinator.data["grid"][self.entity_description.key] # type: ignore[no-any-return] async def async_setup_entry( @@ -213,7 +212,7 @@ async def async_setup_entry( current: dict[str, CurrentInterval] = coordinator.data["current"] forecasts: dict[str, list[ForecastInterval]] = coordinator.data["forecasts"] - entities: list = [] + entities: list[SensorEntity] = [] for channel_type in current: description = SensorEntityDescription( key="current", diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index 2762c3948a7644..fc192d8658f983 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -6,6 +6,7 @@ from typing import Any import ambiclimate +from ambiclimate import AmbiclimateDevice import voluptuous as vol from homeassistant.components.climate import ( @@ -157,13 +158,13 @@ class AmbiclimateEntity(ClimateEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, heater, store): + def __init__(self, heater: AmbiclimateDevice, store: Store[dict[str, Any]]) -> None: """Initialize the thermostat.""" self._heater = heater self._store = store self._attr_unique_id = heater.device_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, + identifiers={(DOMAIN, self.unique_id)}, # type: ignore[arg-type] manufacturer="Ambiclimate", name=heater.name, ) diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 0d259cf337a12a..383a11055e4457 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Ambiclimate.""" import logging +from typing import Any from aiohttp import web import ambiclimate @@ -7,7 +8,8 @@ from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.network import get_url from homeassistant.helpers.storage import Store @@ -26,7 +28,9 @@ @callback -def register_flow_implementation(hass, client_id, client_secret): +def register_flow_implementation( + hass: HomeAssistant, client_id: str, client_secret: str +) -> None: """Register a ambiclimate implementation. client_id: Client id. @@ -50,7 +54,9 @@ def __init__(self) -> None: self._registered_view = False self._oauth = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle external yaml configuration.""" self._async_abort_entries_match() @@ -62,7 +68,9 @@ async def async_step_user(self, user_input=None): return await self.async_step_auth() - async def async_step_auth(self, user_input=None): + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow start.""" self._async_abort_entries_match() @@ -83,7 +91,7 @@ async def async_step_auth(self, user_input=None): errors=errors, ) - async def async_step_code(self, code=None): + async def async_step_code(self, code: str | None = None) -> FlowResult: """Received code for authentication.""" self._async_abort_entries_match() @@ -95,7 +103,7 @@ async def async_step_code(self, code=None): return self.async_create_entry(title="Ambiclimate", data=config) - async def _get_token_info(self, code): + async def _get_token_info(self, code: str | None) -> dict[str, Any] | None: oauth = self._generate_oauth() try: token_info = await oauth.get_access_token(code) @@ -103,16 +111,16 @@ async def _get_token_info(self, code): _LOGGER.exception("Failed to get access token") return None - store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY) + store = Store[dict[str, Any]](self.hass, STORAGE_VERSION, STORAGE_KEY) await store.async_save(token_info) - return token_info + return token_info # type: ignore[no-any-return] - def _generate_view(self): + def _generate_view(self) -> None: self.hass.http.register_view(AmbiclimateAuthCallbackView()) self._registered_view = True - def _generate_oauth(self): + def _generate_oauth(self) -> ambiclimate.AmbiclimateOAuth: config = self.hass.data[DATA_AMBICLIMATE_IMPL] clientsession = async_get_clientsession(self.hass) callback_url = self._cb_url() @@ -124,12 +132,12 @@ def _generate_oauth(self): clientsession, ) - def _cb_url(self): + def _cb_url(self) -> str: return f"{get_url(self.hass, prefer_external=True)}{AUTH_CALLBACK_PATH}" - async def _get_authorize_url(self): + async def _get_authorize_url(self) -> str: oauth = self._generate_oauth() - return oauth.get_authorize_url() + return oauth.get_authorize_url() # type: ignore[no-any-return] class AmbiclimateAuthCallbackView(HomeAssistantView): diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index 49ff43bcc7eb04..8bdfe0fd64235f 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -63,14 +63,14 @@ TYPE_RELAY9 = "relay9" -@dataclass +@dataclass(frozen=True) class AmbientBinarySensorDescriptionMixin: """Define an entity description mixin for binary sensors.""" on_state: Literal[0, 1] -@dataclass +@dataclass(frozen=True) class AmbientBinarySensorDescription( BinarySensorEntityDescription, AmbientBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index e71a5cda538ba3..a0b6b4f6527c3d 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -35,7 +35,7 @@ from . import AmcrestDevice -@dataclass +@dataclass(frozen=True) class AmcrestSensorEntityDescription(BinarySensorEntityDescription): """Describe Amcrest sensor entity.""" diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py index 7a08d774f6a059..d7a821d956aed4 100644 --- a/homeassistant/components/android_ip_webcam/sensor.py +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -23,14 +23,14 @@ from .entity import AndroidIPCamBaseEntity -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[PyDroidIPCam], StateType] -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSensorEntityDescription( SensorEntityDescription, AndroidIPWebcamSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/android_ip_webcam/strings.json b/homeassistant/components/android_ip_webcam/strings.json index db21a69098477e..57e5452b900aa9 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/android_ip_webcam/switch.py b/homeassistant/components/android_ip_webcam/switch.py index 1eca19fe395379..bae847390798d3 100644 --- a/homeassistant/components/android_ip_webcam/switch.py +++ b/homeassistant/components/android_ip_webcam/switch.py @@ -18,7 +18,7 @@ from .entity import AndroidIPCamBaseEntity -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -26,7 +26,7 @@ class AndroidIPWebcamSwitchEntityDescriptionMixin: off_func: Callable[[PyDroidIPCam], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSwitchEntityDescription( SwitchEntityDescription, AndroidIPWebcamSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 4a1ad55e0b177f..cd9e42aeb4d45d 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -28,7 +28,7 @@ EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -166,7 +166,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not aftv: raise ConfigEntryNotReady(error_message) - async def async_close_connection(event): + async def async_close_connection(event: Event) -> None: """Close Android Debug Bridge connection on HA Stop.""" await aftv.adb_close() diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index 7e2b1e85f39911..e688b0a92def12 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -385,4 +385,4 @@ def _validate_state_det_rules(state_det_rules: Any) -> list[Any] | None: except ValueError as exc: _LOGGER.warning("Invalid state detection rules: %s", exc) return None - return json_rules + return json_rules # type: ignore[no-any-return] diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 1fec605d8e1fac..bd058ac769e133 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -160,7 +160,7 @@ def adb_decorator( """ def _adb_decorator( - func: _FuncType[_ADBDeviceT, _P, _R] + func: _FuncType[_ADBDeviceT, _P, _R], ) -> _ReturnFuncType[_ADBDeviceT, _P, _R]: """Wrap the provided ADB method and catch exceptions.""" @@ -313,7 +313,7 @@ async def async_added_to_hass(self) -> None: @adb_decorator() async def _adb_screencap(self) -> bytes | None: """Take a screen capture from the device.""" - return await self.aftv.adb_screencap() + return await self.aftv.adb_screencap() # type: ignore[no-any-return] async def _async_get_screencap(self, prev_app_id: str | None = None) -> None: """Take a screen capture from the device when enabled.""" @@ -331,7 +331,7 @@ async def _async_get_screencap(self, prev_app_id: str | None = None) -> None: await self._adb_get_screencap(no_throttle=force) @Throttle(MIN_TIME_BETWEEN_SCREENCAPS) - async def _adb_get_screencap(self, **kwargs) -> None: + async def _adb_get_screencap(self, **kwargs: Any) -> None: """Take a screen capture from the device every 60 seconds.""" if media_data := await self._adb_screencap(): self._media_image = media_data, "image/png" diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 9471504808c3cd..c78321589a9d3b 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN @@ -69,7 +69,7 @@ def reauth_needed() -> None: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def on_hass_stop(event) -> None: + def on_hass_stop(event: Event) -> None: """Stop push updates when hass stops.""" api.disconnect() diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 19d1a2deaff65e..827fc0037a74b9 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -5,7 +5,7 @@ import logging from typing import Any -from anel_pwrctrl import DeviceMaster +from anel_pwrctrl import Device, DeviceMaster, Switch import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity @@ -72,7 +72,7 @@ def setup_platform( class PwrCtrlSwitch(SwitchEntity): """Representation of a PwrCtrl switch.""" - def __init__(self, port, parent_device): + def __init__(self, port: Switch, parent_device: PwrCtrlDevice) -> None: """Initialize the PwrCtrl switch.""" self._port = port self._parent_device = parent_device @@ -96,11 +96,11 @@ def turn_off(self, **kwargs: Any) -> None: class PwrCtrlDevice: """Device representation for per device throttling.""" - def __init__(self, device): + def __init__(self, device: Device) -> None: """Initialize the PwrCtrl device.""" self._device = device @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def update(self) -> None: """Update the device and all its switches.""" self._device.update() diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index 6336aa61e1c2d0..b7657e26249056 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -23,14 +23,14 @@ from .models import AnovaData -@dataclass +@dataclass(frozen=True) class AnovaSensorEntityDescriptionMixin: """Describes the mixin variables for anova sensors.""" value_fn: Callable[[APCUpdateSensor], float | int | str] -@dataclass +@dataclass(frozen=True) class AnovaSensorEntityDescription( SensorEntityDescription, AnovaSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index e75c67cb2c521a..892c40cde0e60d 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -10,18 +10,12 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac -from .const import ( - CONF_MODEL, - DEFAULT_NAME, - DEFAULT_PORT, - DEVICE_TIMEOUT_SECONDS, - DOMAIN, -) +from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/anthemav/const.py b/homeassistant/components/anthemav/const.py index 2b1ff753fba6b3..7cf586fb05d845 100644 --- a/homeassistant/components/anthemav/const.py +++ b/homeassistant/components/anthemav/const.py @@ -1,6 +1,6 @@ """Constants for the Anthem A/V Receivers integration.""" ANTHEMAV_UPDATE_SIGNAL = "anthemav_update" -CONF_MODEL = "model" + DEFAULT_NAME = "Anthem AV" DEFAULT_PORT = 14999 DOMAIN = "anthemav" diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 91f8536d348f46..c13e6389bfc2d9 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -13,13 +13,13 @@ MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC +from homeassistant.const import CONF_MAC, CONF_MODEL 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 ANTHEMAV_UPDATE_SIGNAL, CONF_MODEL, DOMAIN, MANUFACTURER +from .const import ANTHEMAV_UPDATE_SIGNAL, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py new file mode 100644 index 00000000000000..b75a4ad7295184 --- /dev/null +++ b/homeassistant/components/aosmith/__init__.py @@ -0,0 +1,73 @@ +"""The A. O. Smith integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from py_aosmith import AOSmithAPIClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, device_registry as dr + +from .const import DOMAIN +from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER] + + +@dataclass +class AOSmithData: + """Data for the A. O. Smith integration.""" + + client: AOSmithAPIClient + status_coordinator: AOSmithStatusCoordinator + energy_coordinator: AOSmithEnergyCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up A. O. Smith from a config entry.""" + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + + session = aiohttp_client.async_get_clientsession(hass) + client = AOSmithAPIClient(email, password, session) + + status_coordinator = AOSmithStatusCoordinator(hass, client) + await status_coordinator.async_config_entry_first_refresh() + + device_registry = dr.async_get(hass) + for junction_id, status_data in status_coordinator.data.items(): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, junction_id)}, + manufacturer="A. O. Smith", + name=status_data.get("name"), + model=status_data.get("model"), + serial_number=status_data.get("serial"), + suggested_area=status_data.get("install", {}).get("location"), + sw_version=status_data.get("data", {}).get("firmwareVersion"), + ) + + energy_coordinator = AOSmithEnergyCoordinator( + hass, client, list(status_coordinator.data) + ) + await energy_coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AOSmithData( + client, + status_coordinator, + energy_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/aosmith/config_flow.py b/homeassistant/components/aosmith/config_flow.py new file mode 100644 index 00000000000000..899b738235907f --- /dev/null +++ b/homeassistant/components/aosmith/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for A. O. Smith integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from py_aosmith import AOSmithAPIClient, AOSmithInvalidCredentialsException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for A. O. Smith.""" + + VERSION = 1 + + _reauth_email: str | None = None + + async def _async_validate_credentials( + self, email: str, password: str + ) -> str | None: + """Validate the credentials. Return an error string, or None if successful.""" + session = aiohttp_client.async_get_clientsession(self.hass) + client = AOSmithAPIClient(email, password, session) + + try: + await client.get_devices() + except AOSmithInvalidCredentialsException: + return "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return "unknown" + + return 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: + unique_id = user_input[CONF_EMAIL].lower() + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + error = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if error is None: + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth if the user credentials have changed.""" + self._reauth_email = entry_data[CONF_EMAIL] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user's reauth credentials.""" + errors: dict[str, str] = {} + if user_input is not None and self._reauth_email is not None: + email = self._reauth_email + password = user_input[CONF_PASSWORD] + entry_id = self.context["entry_id"] + + if entry := self.hass.config_entries.async_get_entry(entry_id): + error = await self._async_validate_credentials(email, password) + if error is None: + self.hass.config_entries.async_update_entry( + entry, + data=entry.data | user_input, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + errors["base"] = error + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={CONF_EMAIL: self._reauth_email}, + errors=errors, + ) diff --git a/homeassistant/components/aosmith/const.py b/homeassistant/components/aosmith/const.py new file mode 100644 index 00000000000000..c0c693e0dac2c5 --- /dev/null +++ b/homeassistant/components/aosmith/const.py @@ -0,0 +1,25 @@ +"""Constants for the A. O. Smith integration.""" + +from datetime import timedelta + +DOMAIN = "aosmith" + +AOSMITH_MODE_ELECTRIC = "ELECTRIC" +AOSMITH_MODE_HEAT_PUMP = "HEAT_PUMP" +AOSMITH_MODE_HYBRID = "HYBRID" +AOSMITH_MODE_VACATION = "VACATION" + +# Update interval to be used for normal background updates. +REGULAR_INTERVAL = timedelta(seconds=30) + +# Update interval to be used while a mode or setpoint change is in progress. +FAST_INTERVAL = timedelta(seconds=1) + +# Update interval to be used for energy usage data. +ENERGY_USAGE_INTERVAL = timedelta(minutes=10) + +HOT_WATER_STATUS_MAP = { + "LOW": "low", + "MEDIUM": "medium", + "HIGH": "high", +} diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py new file mode 100644 index 00000000000000..7d6053cc86e18d --- /dev/null +++ b/homeassistant/components/aosmith/coordinator.py @@ -0,0 +1,83 @@ +"""The data update coordinator for the A. O. Smith integration.""" +import logging +from typing import Any + +from py_aosmith import ( + AOSmithAPIClient, + AOSmithInvalidCredentialsException, + AOSmithUnknownException, +) + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, ENERGY_USAGE_INTERVAL, FAST_INTERVAL, REGULAR_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Coordinator for device status, updating with a frequent interval.""" + + def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None: + """Initialize the coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=REGULAR_INTERVAL) + self.client = client + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch latest data from the device status endpoint.""" + try: + devices = await self.client.get_devices() + except AOSmithInvalidCredentialsException as err: + raise ConfigEntryAuthFailed from err + except AOSmithUnknownException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + mode_pending = any( + device.get("data", {}).get("modePending") for device in devices + ) + setpoint_pending = any( + device.get("data", {}).get("temperatureSetpointPending") + for device in devices + ) + + if mode_pending or setpoint_pending: + self.update_interval = FAST_INTERVAL + else: + self.update_interval = REGULAR_INTERVAL + + return {device.get("junctionId"): device for device in devices} + + +class AOSmithEnergyCoordinator(DataUpdateCoordinator[dict[str, float]]): + """Coordinator for energy usage data, updating with a slower interval.""" + + def __init__( + self, + hass: HomeAssistant, + client: AOSmithAPIClient, + junction_ids: list[str], + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=ENERGY_USAGE_INTERVAL + ) + self.client = client + self.junction_ids = junction_ids + + async def _async_update_data(self) -> dict[str, float]: + """Fetch latest data from the energy usage endpoint.""" + energy_usage_by_junction_id: dict[str, float] = {} + + for junction_id in self.junction_ids: + try: + energy_usage = await self.client.get_energy_use_data(junction_id) + except AOSmithInvalidCredentialsException as err: + raise ConfigEntryAuthFailed from err + except AOSmithUnknownException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + energy_usage_by_junction_id[junction_id] = energy_usage.get("lifetimeKwh") + + return energy_usage_by_junction_id diff --git a/homeassistant/components/aosmith/entity.py b/homeassistant/components/aosmith/entity.py new file mode 100644 index 00000000000000..107e5d7e944271 --- /dev/null +++ b/homeassistant/components/aosmith/entity.py @@ -0,0 +1,62 @@ +"""The base entity for the A. O. Smith integration.""" +from typing import TypeVar + +from py_aosmith import AOSmithAPIClient + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator + +_AOSmithCoordinatorT = TypeVar( + "_AOSmithCoordinatorT", bound=AOSmithStatusCoordinator | AOSmithEnergyCoordinator +) + + +class AOSmithEntity(CoordinatorEntity[_AOSmithCoordinatorT]): + """Base entity for A. O. Smith.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: _AOSmithCoordinatorT, junction_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.junction_id = junction_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, junction_id)}, + ) + + @property + def client(self) -> AOSmithAPIClient: + """Shortcut to get the API client.""" + return self.coordinator.client + + +class AOSmithStatusEntity(AOSmithEntity[AOSmithStatusCoordinator]): + """Base entity for entities that use data from the status coordinator.""" + + @property + def device(self): + """Shortcut to get the device status from the coordinator data.""" + return self.coordinator.data.get(self.junction_id) + + @property + def device_data(self): + """Shortcut to get the device data within the device status.""" + device = self.device + return None if device is None else device.get("data", {}) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.device_data.get("isOnline") is True + + +class AOSmithEnergyEntity(AOSmithEntity[AOSmithEnergyCoordinator]): + """Base entity for entities that use data from the energy coordinator.""" + + @property + def energy_usage(self) -> float | None: + """Shortcut to get the energy usage from the coordinator data.""" + return self.coordinator.data.get(self.junction_id) diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json new file mode 100644 index 00000000000000..895b03cf7fd9d6 --- /dev/null +++ b/homeassistant/components/aosmith/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "aosmith", + "name": "A. O. Smith", + "codeowners": ["@bdr99"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aosmith", + "iot_class": "cloud_polling", + "requirements": ["py-aosmith==1.0.1"] +} diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py new file mode 100644 index 00000000000000..b0606d2dca4438 --- /dev/null +++ b/homeassistant/components/aosmith/sensor.py @@ -0,0 +1,106 @@ +"""The sensor platform for the A. O. Smith integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AOSmithData +from .const import DOMAIN, HOT_WATER_STATUS_MAP +from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator +from .entity import AOSmithEnergyEntity, AOSmithStatusEntity + + +@dataclass(frozen=True, kw_only=True) +class AOSmithStatusSensorEntityDescription(SensorEntityDescription): + """Entity description class for sensors using data from the status coordinator.""" + + value_fn: Callable[[dict[str, Any]], str | int | None] + + +STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = ( + AOSmithStatusSensorEntityDescription( + key="hot_water_availability", + translation_key="hot_water_availability", + icon="mdi:water-thermometer", + device_class=SensorDeviceClass.ENUM, + options=["low", "medium", "high"], + value_fn=lambda device: HOT_WATER_STATUS_MAP.get( + device.get("data", {}).get("hotWaterStatus") + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up A. O. Smith sensor platform.""" + data: AOSmithData = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + AOSmithStatusSensorEntity(data.status_coordinator, description, junction_id) + for description in STATUS_ENTITY_DESCRIPTIONS + for junction_id in data.status_coordinator.data + ) + + async_add_entities( + AOSmithEnergySensorEntity(data.energy_coordinator, junction_id) + for junction_id in data.status_coordinator.data + ) + + +class AOSmithStatusSensorEntity(AOSmithStatusEntity, SensorEntity): + """Class for sensor entities that use data from the status coordinator.""" + + entity_description: AOSmithStatusSensorEntityDescription + + def __init__( + self, + coordinator: AOSmithStatusCoordinator, + description: AOSmithStatusSensorEntityDescription, + junction_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self.entity_description = description + self._attr_unique_id = f"{description.key}_{junction_id}" + + @property + def native_value(self) -> str | int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) + + +class AOSmithEnergySensorEntity(AOSmithEnergyEntity, SensorEntity): + """Class for the energy sensor entity.""" + + _attr_translation_key = "energy_usage" + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_suggested_display_precision = 1 + + def __init__( + self, + coordinator: AOSmithEnergyCoordinator, + junction_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self._attr_unique_id = f"energy_usage_{junction_id}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.energy_usage diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json new file mode 100644 index 00000000000000..0ca4e2e9094e1b --- /dev/null +++ b/homeassistant/components/aosmith/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Please enter your A. O. Smith credentials." + }, + "reauth_confirm": { + "description": "Please update your password for {email}", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "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_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "entity": { + "sensor": { + "hot_water_availability": { + "name": "Hot water availability", + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } + }, + "energy_usage": { + "name": "Energy usage" + } + } + } +} diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py new file mode 100644 index 00000000000000..8c42048d4393d7 --- /dev/null +++ b/homeassistant/components/aosmith/water_heater.py @@ -0,0 +1,151 @@ +"""The water heater platform for the A. O. Smith integration.""" + +from typing import Any + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AOSmithData +from .const import ( + AOSMITH_MODE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP, + AOSMITH_MODE_HYBRID, + AOSMITH_MODE_VACATION, + DOMAIN, +) +from .coordinator import AOSmithStatusCoordinator +from .entity import AOSmithStatusEntity + +MODE_HA_TO_AOSMITH = { + STATE_OFF: AOSMITH_MODE_VACATION, + STATE_ECO: AOSMITH_MODE_HYBRID, + STATE_ELECTRIC: AOSMITH_MODE_ELECTRIC, + STATE_HEAT_PUMP: AOSMITH_MODE_HEAT_PUMP, +} +MODE_AOSMITH_TO_HA = { + AOSMITH_MODE_ELECTRIC: STATE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP: STATE_HEAT_PUMP, + AOSMITH_MODE_HYBRID: STATE_ECO, + AOSMITH_MODE_VACATION: STATE_OFF, +} + +# Operation mode to use when exiting away mode +DEFAULT_OPERATION_MODE = AOSMITH_MODE_HYBRID + +DEFAULT_SUPPORT_FLAGS = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up A. O. Smith water heater platform.""" + data: AOSmithData = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + AOSmithWaterHeaterEntity(data.status_coordinator, junction_id) + for junction_id in data.status_coordinator.data + ) + + +class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity): + """The water heater entity for the A. O. Smith integration.""" + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_temp = 95 + + def __init__( + self, + coordinator: AOSmithStatusCoordinator, + junction_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self._attr_unique_id = junction_id + + @property + def operation_list(self) -> list[str]: + """Return the list of supported operation modes.""" + op_modes = [] + for mode_dict in self.device_data.get("modes", []): + mode_name = mode_dict.get("mode") + ha_mode = MODE_AOSMITH_TO_HA.get(mode_name) + + # Filtering out STATE_OFF since it is handled by away mode + if ha_mode is not None and ha_mode != STATE_OFF: + op_modes.append(ha_mode) + + return op_modes + + @property + def supported_features(self) -> WaterHeaterEntityFeature: + """Return the list of supported features.""" + supports_vacation_mode = any( + mode_dict.get("mode") == AOSMITH_MODE_VACATION + for mode_dict in self.device_data.get("modes", []) + ) + + if supports_vacation_mode: + return DEFAULT_SUPPORT_FLAGS | WaterHeaterEntityFeature.AWAY_MODE + + return DEFAULT_SUPPORT_FLAGS + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.device_data.get("temperatureSetpoint") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device_data.get("temperatureSetpointMaximum") + + @property + def current_operation(self) -> str: + """Return the current operation mode.""" + return MODE_AOSMITH_TO_HA.get(self.device_data.get("mode"), STATE_OFF) + + @property + def is_away_mode_on(self): + """Return True if away mode is on.""" + return self.device_data.get("mode") == AOSMITH_MODE_VACATION + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + aosmith_mode = MODE_HA_TO_AOSMITH.get(operation_mode) + if aosmith_mode is not None: + await self.client.update_mode(self.junction_id, aosmith_mode) + + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get("temperature") + await self.client.update_setpoint(self.junction_id, temperature) + + await self.coordinator.async_request_refresh() + + async def async_turn_away_mode_on(self) -> None: + """Turn away mode on.""" + await self.client.update_mode(self.junction_id, AOSMITH_MODE_VACATION) + + await self.coordinator.async_request_refresh() + + async def async_turn_away_mode_off(self) -> None: + """Turn away mode off.""" + await self.client.update_mode(self.junction_id, DEFAULT_OPERATION_MODE) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py index c974735791e046..d909fb9f51fd45 100644 --- a/homeassistant/components/apache_kafka/__init__.py +++ b/homeassistant/components/apache_kafka/__init__.py @@ -1,7 +1,10 @@ """Support for Apache Kafka.""" +from __future__ import annotations + from datetime import datetime import json import sys +from typing import Any, Literal import voluptuous as vol @@ -15,11 +18,12 @@ STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entityfilter import FILTER_SCHEMA -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter +from homeassistant.helpers.event import EventStateChangedData +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util import ssl as ssl_util if sys.version_info < (3, 12): @@ -84,11 +88,11 @@ class DateTimeJSONEncoder(json.JSONEncoder): Additionally add encoding for datetime objects as isoformat. """ - def default(self, o): + def default(self, o: Any) -> str: """Implement encoding logic.""" if isinstance(o, datetime): return o.isoformat() - return super().default(o) + return super().default(o) # type: ignore[no-any-return] class KafkaManager: @@ -96,15 +100,15 @@ class KafkaManager: def __init__( self, - hass, - ip_address, - port, - topic, - entities_filter, - security_protocol, - username, - password, - ): + hass: HomeAssistant, + ip_address: str, + port: int, + topic: str, + entities_filter: EntityFilter, + security_protocol: Literal["PLAINTEXT", "SASL_SSL"], + username: str | None, + password: str | None, + ) -> None: """Initialize.""" self._encoder = DateTimeJSONEncoder() self._entities_filter = entities_filter @@ -121,30 +125,30 @@ def __init__( ) self._topic = topic - def _encode_event(self, event): + def _encode_event(self, event: EventType[EventStateChangedData]) -> bytes | None: """Translate events into a binary JSON payload.""" - state = event.data.get("new_state") + state = event.data["new_state"] if ( state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE) or not self._entities_filter(state.entity_id) ): - return + return None return json.dumps(obj=state.as_dict(), default=self._encoder.encode).encode( "utf-8" ) - async def start(self): + async def start(self) -> None: """Start the Kafka manager.""" - self._hass.bus.async_listen(EVENT_STATE_CHANGED, self.write) + self._hass.bus.async_listen(EVENT_STATE_CHANGED, self.write) # type: ignore[arg-type] await self._producer.start() - async def shutdown(self, _): + async def shutdown(self, _: Event) -> None: """Shut the manager down.""" await self._producer.stop() - async def write(self, event): + async def write(self, event: EventType[EventStateChangedData]) -> None: """Write a binary payload to Kafka.""" payload = self._encode_event(event) diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 8d7c6b2f46d78c..550e1014d2a589 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 bac8d18d58bcdd..76e88689ca50c7 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.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 @@ 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 f1ce20694c7b69..57002d7a2b215f 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.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 @@ async def async_step_user( 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 00000000000000..cacc9e2936973b --- /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 00000000000000..98d464ec5267c1 --- /dev/null +++ b/homeassistant/components/apcupsd/coordinator.py @@ -0,0 +1,97 @@ +"""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 + +import aioapcaccess + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + 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. + """ + + config_entry: ConfigEntry + + 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: + """Return the DeviceInfo of this APC UPS, if serial number is available.""" + return DeviceInfo( + identifiers={(DOMAIN, self.ups_serial_no or self.config_entry.entry_id)}, + 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: + return await aioapcaccess.request_status(self._host, self._port) + except (OSError, asyncio.IncompleteReadError) as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index cd7e2a116b31dc..b20e0c8aacfd11 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"], - "requirements": ["apcaccess==0.0.13"] + "quality_scale": "silver", + "requirements": ["aioapcaccess==0.4.2"] } diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 745be7e2d63776..71dc9940b72595 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -20,10 +20,11 @@ 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 - key = self.entity_description.key.upper() - if key not in self._data_service.status: - self._attr_native_value = None - return + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs() + self.async_write_ha_state() - self._attr_native_value, inferred_unit = infer_unit( - self._data_service.status[key] - ) + def _update_attrs(self) -> None: + """Update sensor attributes based on coordinator data.""" + key = self.entity_description.key.upper() + 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 6bb3cc34050ad4..057e85613fd919 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -41,7 +41,6 @@ Unauthorized, ) from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions @@ -218,9 +217,11 @@ def get(self, request: web.Request) -> web.Response: 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, ) - enable_compression(response) + response.enable_compression() return response @@ -390,17 +391,14 @@ def _async_save_changed_entities( ) try: - async with timeout(SERVICE_WAIT_TIMEOUT): - # shield the service call from cancellation on connection drop - await shield( - 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 - except TimeoutError: - pass finally: cancel_listen() diff --git a/homeassistant/components/appalachianpower/__init__.py b/homeassistant/components/appalachianpower/__init__.py new file mode 100644 index 00000000000000..2e3180ba29f87f --- /dev/null +++ b/homeassistant/components/appalachianpower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Appalachian Power.""" diff --git a/homeassistant/components/appalachianpower/manifest.json b/homeassistant/components/appalachianpower/manifest.json new file mode 100644 index 00000000000000..884bd14c3fdc1d --- /dev/null +++ b/homeassistant/components/appalachianpower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "appalachianpower", + "name": "Appalachian Power", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 6a85ea1d1a817c..fc65253fe43ba6 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -127,7 +127,7 @@ def device_identifier(self): def _entry_unique_id_from_identifers(self, all_identifiers: set[str]) -> str | None: """Search existing entries for an identifier and return the unique id.""" for entry in self._async_current_entries(): - if all_identifiers.intersection( + if not all_identifiers.isdisjoint( entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) ): return entry.unique_id @@ -326,7 +326,7 @@ async def async_find_device(self, allow_exist=False): existing_identifiers = set( entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) ) - if not all_identifiers.intersection(existing_identifiers): + if all_identifiers.isdisjoint(existing_identifiers): continue combined_identifiers = existing_identifiers | all_identifiers if entry.data.get( diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py index b215f93aeb1c11..e4b350c4da854a 100644 --- a/homeassistant/components/apprise/notify.py +++ b/homeassistant/components/apprise/notify.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import apprise import voluptuous as vol @@ -61,11 +62,11 @@ def get_service( class AppriseNotificationService(BaseNotificationService): """Implement the notification service for Apprise.""" - def __init__(self, a_obj): + def __init__(self, a_obj: apprise.Apprise) -> None: """Initialize the service.""" self.apprise = a_obj - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a specified target. If no target/tags are specified, then services are notified as is diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index b1467a6d2e4ee9..8b952f88c7c0d1 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -3,6 +3,7 @@ import logging import threading +from typing import Any import aprslib from aprslib import ConnectionError as AprsConnectionError, LoginError @@ -23,7 +24,7 @@ CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify @@ -66,7 +67,7 @@ def make_filter(callsigns: list) -> str: return " ".join(f"b/{sign.upper()}" for sign in callsigns) -def gps_accuracy(gps, posambiguity: int) -> int: +def gps_accuracy(gps: tuple[float, float], posambiguity: int) -> int: """Calculate the GPS accuracy based on APRS posambiguity.""" pos_a_map = {0: 0, 1: 1 / 600, 2: 1 / 60, 3: 1 / 6, 4: 1} @@ -74,7 +75,7 @@ def gps_accuracy(gps, posambiguity: int) -> int: degrees = pos_a_map[posambiguity] gps2 = (gps[0], gps[1] + degrees) - dist_m = geopy.distance.distance(gps, gps2).m + dist_m: float = geopy.distance.distance(gps, gps2).m accuracy = round(dist_m) else: @@ -100,7 +101,7 @@ def setup_scanner( timeout = config[CONF_TIMEOUT] aprs_listener = AprsListenerThread(callsign, password, host, server_filter, see) - def aprs_disconnect(event): + def aprs_disconnect(event: Event) -> None: """Stop the APRS connection.""" aprs_listener.stop() @@ -145,13 +146,13 @@ def __init__( self.callsign, passwd=password, host=self.host, port=FILTER_PORT ) - def start_complete(self, success: bool, message: str): + def start_complete(self, success: bool, message: str) -> None: """Complete startup process.""" self.start_message = message self.start_success = success self.start_event.set() - def run(self): + def run(self) -> None: """Connect to APRS and listen for data.""" self.ais.set_filter(self.server_filter) @@ -171,11 +172,11 @@ def run(self): "Closing connection to %s with callsign %s", self.host, self.callsign ) - def stop(self): + def stop(self) -> None: """Close the connection to the APRS network.""" self.ais.close() - def rx_msg(self, msg: dict): + def rx_msg(self, msg: dict[str, Any]) -> None: """Receive message and process if position.""" _LOGGER.debug("APRS message received: %s", str(msg)) if msg[ATTR_FORMAT] in MSG_FORMATS: diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 955293a938ea1a..90f87bfde2396d 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -26,7 +26,7 @@ from . import DOMAIN, UPDATE_TOPIC, AquaLogicProcessor -@dataclass +@dataclass(frozen=True) class AquaLogicSensorEntityDescription(SensorEntityDescription): """Describes AquaLogic sensor entity.""" diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 34d5e4161fbfd3..a87756334e21de 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -1,7 +1,9 @@ """Support for interface with an Aquos TV.""" from __future__ import annotations +from collections.abc import Callable import logging +from typing import Any, Concatenate, ParamSpec, TypeVar import sharp_aquos_rc import voluptuous as vol @@ -25,6 +27,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +_SharpAquosTVDeviceT = TypeVar("_SharpAquosTVDeviceT", bound="SharpAquosTVDevice") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Sharp Aquos TV" @@ -79,10 +84,12 @@ def setup_platform( add_entities([SharpAquosTVDevice(name, remote, power_on_enabled)]) -def _retry(func): +def _retry( + func: Callable[Concatenate[_SharpAquosTVDeviceT, _P], Any], +) -> Callable[Concatenate[_SharpAquosTVDeviceT, _P], None]: """Handle query retries.""" - def wrapper(obj, *args, **kwargs): + def wrapper(obj: _SharpAquosTVDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap all query functions.""" update_retries = 5 while update_retries > 0: @@ -125,7 +132,7 @@ def __init__( # Assume that the TV is not muted self._remote = remote - def set_state(self, state): + def set_state(self, state: MediaPlayerState) -> None: """Set TV state.""" self._attr_state = state diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index ad11b4bdbdc6cd..23d3b64fdcaf84 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -39,7 +39,7 @@ from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class AranetSensorEntityDescription(SensorEntityDescription): """Class to describe an Aranet sensor entity.""" diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 12114ec04b848f..7c4ec280101b71 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -1,9 +1,10 @@ """Arcam media player.""" from __future__ import annotations +from collections.abc import Callable, Coroutine import functools import logging -from typing import Any +from typing import Any, ParamSpec, TypeVar from arcam.fmj import ConnectionFailed, SourceCodes from arcam.fmj.state import State @@ -34,6 +35,9 @@ SIGNAL_CLIENT_STOPPED, ) +_R = TypeVar("_R") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) @@ -59,11 +63,13 @@ async def async_setup_entry( ) -def convert_exception(func): +def convert_exception( + func: Callable[_P, Coroutine[Any, Any, _R]], +) -> Callable[_P, Coroutine[Any, Any, _R]]: """Return decorator to convert a connection error into a home assistant error.""" @functools.wraps(func) - async def _convert_exception(*args, **kwargs): + async def _convert_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R: try: return await func(*args, **kwargs) except ConnectionFailed as exception: diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index 48b8d9f13c4798..bb917af5c39d0b 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -40,13 +40,13 @@ def __init__(self, connect_box: ConnectBox) -> None: self.connect_box = connect_box self.last_results: list[Device] = [] - def scan_devices(self): + def scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [device.mac for device in self.last_results] + return [device.mac for device in self.last_results if device.mac] - def get_device_name(self, device): + def get_device_name(self, device: str) -> str | None: """Return the name of the given device or None if we don't know.""" name = next( (result.hostname for result in self.last_results if result.mac == device), @@ -54,12 +54,12 @@ def get_device_name(self, device): ) return name - def _update_info(self): + def _update_info(self) -> None: """Ensure the information from the Arris TG2492LG router is up to date.""" result = self.connect_box.get_connected_devices() - last_results = [] - mac_addresses = set() + last_results: list[Device] = [] + mac_addresses: set[str | None] = set() for device in result: if device.online and device.mac not in mac_addresses: diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 7b8c547fd53680..1b449450cf81ac 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -3,6 +3,7 @@ import logging import re +from typing import Any import pexpect import voluptuous as vol @@ -44,33 +45,33 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArubaDeviceScanner | class ArubaDeviceScanner(DeviceScanner): """Class which queries a Aruba Access Point for connected devices.""" - def __init__(self, config): + def __init__(self, config: dict[str, Any]) -> None: """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] + self.host: str = config[CONF_HOST] + self.username: str = config[CONF_USERNAME] + self.password: str = config[CONF_PASSWORD] - self.last_results = {} + self.last_results: dict[str, dict[str, str]] = {} # Test the router is accessible. data = self.get_aruba_data() self.success_init = data is not None - def scan_devices(self): + def scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [client["mac"] for client in self.last_results] + return [client["mac"] for client in self.last_results.values()] - def get_device_name(self, device): + def get_device_name(self, device: str) -> str | None: """Return the name of the given device or None if we don't know.""" if not self.last_results: return None - for client in self.last_results: + for client in self.last_results.values(): if client["mac"] == device: return client["name"] return None - def _update_info(self): + def _update_info(self) -> bool: """Ensure the information from the Aruba Access Point is up to date. Return boolean if scanning successful. @@ -81,10 +82,10 @@ def _update_info(self): if not (data := self.get_aruba_data()): return False - self.last_results = data.values() + self.last_results = data return True - def get_aruba_data(self): + def get_aruba_data(self) -> dict[str, dict[str, str]] | None: """Retrieve data from Aruba Access Point and return parsed result.""" connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa" @@ -103,22 +104,22 @@ def get_aruba_data(self): ) if query == 1: _LOGGER.error("Timeout") - return + return None if query == 2: _LOGGER.error("Unexpected response from router") - return + return None if query == 3: ssh.sendline("yes") ssh.expect("password:") elif query == 4: _LOGGER.error("Host key changed") - return + return None elif query == 5: _LOGGER.error("Connection refused by server") - return + return None elif query == 6: _LOGGER.error("Connection timed out") - return + return None ssh.sendline(self.password) ssh.expect("#") ssh.sendline("show clients") @@ -126,7 +127,7 @@ def get_aruba_data(self): devices_result = ssh.before.split(b"\r\n") ssh.sendline("exit") - devices = {} + devices: dict[str, dict[str, str]] = {} for device in devices_result: if match := _DEVICES_REGEX.search(device.decode("utf-8")): devices[match.group("ip")] = { diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index d468a93eca05dd..caf7dc6f45ea93 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components import mqtt from homeassistant.components.sensor import SensorDeviceClass, SensorEntity @@ -20,7 +21,7 @@ TOPIC = "arwn/#" -def discover_sensors(topic, payload): +def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | None: """Given a topic, dynamically create the right sensor type. Async friendly. @@ -34,22 +35,26 @@ def discover_sensors(topic, payload): unit = UnitOfTemperature.FAHRENHEIT else: unit = UnitOfTemperature.CELSIUS - return ArwnSensor( - topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE - ) + return [ + ArwnSensor( + topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE + ) + ] if domain == "moisture": name = f"{parts[2]} Moisture" - return ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent") + return [ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent")] if domain == "rain": if len(parts) >= 3 and parts[2] == "today": - return ArwnSensor( - topic, - "Rain Since Midnight", - "since_midnight", - UnitOfPrecipitationDepth.INCHES, - device_class=SensorDeviceClass.PRECIPITATION, - ) - return ( + return [ + ArwnSensor( + topic, + "Rain Since Midnight", + "since_midnight", + UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + ) + ] + return [ ArwnSensor( topic + "/total", "Total Rainfall", @@ -64,11 +69,13 @@ def discover_sensors(topic, payload): unit, device_class=SensorDeviceClass.PRECIPITATION, ), - ) + ] if domain == "barometer": - return ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines") + return [ + ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines") + ] if domain == "wind": - return ( + return [ ArwnSensor( topic + "/speed", "Wind Speed", @@ -86,10 +93,11 @@ def discover_sensors(topic, payload): ArwnSensor( topic + "/dir", "Wind Direction", "direction", DEGREE, "mdi:compass" ), - ) + ] + return None -def _slug(name): +def _slug(name: str) -> str: return f"sensor.arwn_{slugify(name)}" @@ -128,9 +136,6 @@ def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None: if (store := hass.data.get(DATA_ARWN)) is None: store = hass.data[DATA_ARWN] = {} - if isinstance(sensors, ArwnSensor): - sensors = (sensors,) - if "timestamp" in event: del event["timestamp"] @@ -159,7 +164,15 @@ class ArwnSensor(SensorEntity): _attr_should_poll = False - def __init__(self, topic, name, state_key, units, icon=None, device_class=None): + def __init__( + self, + topic: str, + name: str, + state_key: str, + units: str, + icon: str | None = None, + device_class: SensorDeviceClass | None = None, + ) -> None: """Initialize the sensor.""" self.entity_id = _slug(name) self._attr_name = name @@ -170,9 +183,9 @@ def __init__(self, topic, name, state_key, units, icon=None, device_class=None): self._attr_icon = icon self._attr_device_class = device_class - def set_event(self, event): + def set_event(self, event: dict[str, Any]) -> None: """Update the sensor with the most recent event.""" - ev = {} + ev: dict[str, Any] = {} ev.update(event) self._attr_extra_state_attributes = ev self._attr_native_value = ev.get(self._state_key, None) diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index 3e0e57fffac62d..cc91b6b97a6e49 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -20,14 +20,14 @@ from .entity import AsekoEntity -@dataclass +@dataclass(frozen=True) class AsekoBinarySensorDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Unit], bool] -@dataclass +@dataclass(frozen=True) class AsekoBinarySensorEntityDescription( BinarySensorEntityDescription, AsekoBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 64fe9e1f5f4681..7f6bef6e3c0c95 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -9,7 +9,13 @@ 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, @@ -25,6 +31,7 @@ async_get_pipeline, async_get_pipelines, async_setup_pipeline_store, + async_update_pipeline, ) from .websocket_api import async_register_websocket_api @@ -34,12 +41,14 @@ "async_get_pipelines", "async_setup", "async_pipeline_from_audio_stream", + "async_update_pipeline", "AudioSettings", "Pipeline", "PipelineEvent", "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 84b49fc18facae..091b19db69e097 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -11,3 +11,5 @@ 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 00000000000000..0c00c57adb9ba1 --- /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 c6d0f6c54352e6..71136dcdecb5ee 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -9,7 +9,7 @@ from enum import StrEnum import logging from pathlib import Path -from queue import Queue +from queue import Empty, Queue from threading import Thread import time from typing import TYPE_CHECKING, Any, Final, cast @@ -43,6 +43,7 @@ ) from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import ( dt as dt_util, language as language_util, @@ -115,6 +116,7 @@ async def _async_resolve_default_pipeline_settings( hass: HomeAssistant, stt_engine_id: str | None, tts_engine_id: str | None, + pipeline_name: str, ) -> dict[str, str | None]: """Resolve settings for a default pipeline. @@ -123,7 +125,6 @@ async def _async_resolve_default_pipeline_settings( """ conversation_language = "en" pipeline_language = "en" - pipeline_name = "Home Assistant" stt_engine = None stt_language = None tts_engine = None @@ -195,9 +196,6 @@ async def _async_resolve_default_pipeline_settings( ) tts_engine_id = None - if stt_engine_id == "cloud" and tts_engine_id == "cloud": - pipeline_name = "Home Assistant Cloud" - return { "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": conversation_language, @@ -221,12 +219,17 @@ async def _async_create_default_pipeline( The default pipeline will use the homeassistant conversation agent and the default stt / tts engines. """ - pipeline_settings = await _async_resolve_default_pipeline_settings(hass, None, None) + pipeline_settings = await _async_resolve_default_pipeline_settings( + hass, stt_engine_id=None, tts_engine_id=None, pipeline_name="Home Assistant" + ) return await pipeline_store.async_create_item(pipeline_settings) async def async_create_default_pipeline( - hass: HomeAssistant, stt_engine_id: str, tts_engine_id: str + hass: HomeAssistant, + stt_engine_id: str, + tts_engine_id: str, + pipeline_name: str, ) -> Pipeline | None: """Create a pipeline with default settings. @@ -236,7 +239,7 @@ async def async_create_default_pipeline( pipeline_data: PipelineData = hass.data[DOMAIN] pipeline_store = pipeline_data.pipeline_store pipeline_settings = await _async_resolve_default_pipeline_settings( - hass, stt_engine_id, tts_engine_id + hass, stt_engine_id, tts_engine_id, pipeline_name=pipeline_name ) if ( pipeline_settings["stt_engine"] != stt_engine_id @@ -274,6 +277,48 @@ def async_get_pipelines(hass: HomeAssistant) -> Iterable[Pipeline]: return pipeline_data.pipeline_store.data.values() +async def async_update_pipeline( + hass: HomeAssistant, + pipeline: Pipeline, + *, + conversation_engine: str | UndefinedType = UNDEFINED, + conversation_language: str | UndefinedType = UNDEFINED, + language: str | UndefinedType = UNDEFINED, + name: str | UndefinedType = UNDEFINED, + stt_engine: str | None | UndefinedType = UNDEFINED, + stt_language: str | None | UndefinedType = UNDEFINED, + tts_engine: str | None | UndefinedType = UNDEFINED, + tts_language: str | None | UndefinedType = UNDEFINED, + tts_voice: str | None | UndefinedType = UNDEFINED, + wake_word_entity: str | None | UndefinedType = UNDEFINED, + wake_word_id: str | None | UndefinedType = UNDEFINED, +) -> None: + """Update a pipeline.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + + updates: dict[str, Any] = pipeline.to_json() + updates.pop("id") + # Refactor this once we bump to Python 3.12 + # and have https://peps.python.org/pep-0692/ + for key, val in ( + ("conversation_engine", conversation_engine), + ("conversation_language", conversation_language), + ("language", language), + ("name", name), + ("stt_engine", stt_engine), + ("stt_language", stt_language), + ("tts_engine", tts_engine), + ("tts_language", tts_language), + ("tts_voice", tts_voice), + ("wake_word_entity", wake_word_entity), + ("wake_word_id", wake_word_id), + ): + if val is not UNDEFINED: + updates[key] = val + + await pipeline_data.pipeline_store.async_update_item(pipeline.id, updates) + + class PipelineEventType(StrEnum): """Event types emitted during a pipeline run.""" @@ -320,7 +365,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: @@ -369,6 +414,7 @@ class PipelineStage(StrEnum): STT = "stt" INTENT = "intent" TTS = "tts" + END = "end" PIPELINE_STAGE_ORDER = [ @@ -482,7 +528,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 +549,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 +603,8 @@ def process_event(self, event: PipelineEvent) -> None: 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 +617,9 @@ def start(self, device_id: str | None) -> None: 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 +799,7 @@ async def _wake_word_audio_stream( 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 +921,7 @@ async def _speech_to_text_stream( 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): @@ -1006,8 +1056,8 @@ async def prepare_text_to_speech(self) -> None: self.tts_engine = engine self.tts_options = tts_options - async def text_to_speech(self, tts_input: str) -> str: - """Run text-to-speech portion of pipeline. Returns URL of TTS audio.""" + async def text_to_speech(self, tts_input: str) -> None: + """Run text-to-speech portion of pipeline.""" self.process_event( PipelineEvent( PipelineEventType.TTS_START, @@ -1042,22 +1092,37 @@ async def text_to_speech(self, tts_input: str) -> str: ) from src_error _LOGGER.debug("TTS result %s", tts_media) + tts_output = { + "media_id": tts_media_id, + **asdict(tts_media), + } 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 _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 - def _start_debug_recording_thread(self, device_id: str | None) -> None: + 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 @@ -1068,7 +1133,7 @@ def _start_debug_recording_thread(self, device_id: str | None) -> None: 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) @@ -1079,7 +1144,7 @@ def _start_debug_recording_thread(self, device_id: str | None) -> None: # /// run_recording_dir = ( Path(debug_recording_dir) - / device_id + / self._device_id / self.pipeline.name / str(time.monotonic_ns()) ) @@ -1100,8 +1165,8 @@ async def _stop_debug_recording_thread(self) -> None: # 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) @@ -1222,6 +1287,8 @@ def _pipeline_debug_recording_thread_proc( # Chunk of 16-bit mono audio at 16Khz if wav_writer is not None: wav_writer.writeframes(message) + except Empty: + pass # occurs when pipeline has unexpected error except Exception: # pylint: disable=broad-exception-caught _LOGGER.exception("Unexpected error in debug recording thread") finally: @@ -1290,9 +1357,9 @@ async def execute(self) -> None: 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 @@ -1321,7 +1388,11 @@ async def buffer_then_audio_stream() -> AsyncGenerator[ self.conversation_id, self.device_id, ) - current_stage = PipelineStage.TTS + if tts_input.strip(): + current_stage = PipelineStage.TTS + else: + # Skip TTS + current_stage = PipelineStage.END if self.run.end_stage != PipelineStage.INTENT: # text-to-speech @@ -1451,7 +1522,7 @@ async def _process_create_data(self, data: dict) -> dict: @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.""" @@ -1632,6 +1703,20 @@ async def _change_listener( 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.""" @@ -1641,6 +1726,7 @@ def __init__(self, pipeline_store: PipelineStorageCollection) -> None: 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/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index fda3e266490ede..89cced519df96f 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -3,22 +3,31 @@ # 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 @@ _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/asterisk_cdr/mailbox.py b/homeassistant/components/asterisk_cdr/mailbox.py index a6c246831af0d1..971b893ef6b023 100644 --- a/homeassistant/components/asterisk_cdr/mailbox.py +++ b/homeassistant/components/asterisk_cdr/mailbox.py @@ -3,6 +3,7 @@ import datetime import hashlib +from typing import Any from homeassistant.components.asterisk_mbox import ( DOMAIN as ASTERISK_DOMAIN, @@ -28,21 +29,21 @@ async def async_get_handler( class AsteriskCDR(Mailbox): """Asterisk VM Call Data Record mailbox.""" - def __init__(self, hass, name): + def __init__(self, hass: HomeAssistant, name: str) -> None: """Initialize Asterisk CDR.""" super().__init__(hass, name) - self.cdr = [] + self.cdr: list[dict[str, Any]] = [] async_dispatcher_connect(self.hass, SIGNAL_CDR_UPDATE, self._update_callback) @callback - def _update_callback(self, msg): + def _update_callback(self, msg: list[dict[str, Any]]) -> Any: """Update the message count in HA, if needed.""" self._build_message() self.async_update() - def _build_message(self): + def _build_message(self) -> None: """Build message structure.""" - cdr = [] + cdr: list[dict[str, Any]] = [] for entry in self.hass.data[ASTERISK_DOMAIN].cdr: timestamp = datetime.datetime.strptime( entry["time"], "%Y-%m-%d %H:%M:%S" @@ -61,7 +62,7 @@ def _build_message(self): cdr.append({"info": info, "sha": sha, "text": msg}) self.cdr = cdr - async def async_get_messages(self): + async def async_get_messages(self) -> list[dict[str, Any]]: """Return a list of the current messages.""" if not self.cdr: self._build_message() diff --git a/homeassistant/components/asterisk_mbox/__init__.py b/homeassistant/components/asterisk_mbox/__init__.py index 607daad5b548d5..e4c80a5848d7f5 100644 --- a/homeassistant/components/asterisk_mbox/__init__.py +++ b/homeassistant/components/asterisk_mbox/__init__.py @@ -1,5 +1,6 @@ """Support for Asterisk Voicemail interface.""" import logging +from typing import Any, cast from asterisk_mbox import Client as asteriskClient from asterisk_mbox.commands import ( @@ -42,11 +43,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up for the Asterisk Voicemail box.""" - conf = config[DOMAIN] + conf: dict[str, Any] = config[DOMAIN] - host = conf[CONF_HOST] - port = conf[CONF_PORT] - password = conf[CONF_PASSWORD] + host: str = conf[CONF_HOST] + port: int = conf[CONF_PORT] + password: str = conf[CONF_PASSWORD] hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config) @@ -56,13 +57,20 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class AsteriskData: """Store Asterisk mailbox data.""" - def __init__(self, hass, host, port, password, config): + def __init__( + self, + hass: HomeAssistant, + host: str, + port: int, + password: str, + config: dict[str, Any], + ) -> None: """Init the Asterisk data object.""" self.hass = hass self.config = config - self.messages = None - self.cdr = None + self.messages: list[dict[str, Any]] | None = None + self.cdr: list[dict[str, Any]] | None = None dispatcher_connect(self.hass, SIGNAL_MESSAGE_REQUEST, self._request_messages) dispatcher_connect(self.hass, SIGNAL_CDR_REQUEST, self._request_cdr) @@ -71,7 +79,7 @@ def __init__(self, hass, host, port, password, config): self.client = asteriskClient(host, port, password, self.handle_data) @callback - def _discover_platform(self, component): + def _discover_platform(self, component: str) -> None: _LOGGER.debug("Adding mailbox %s", component) self.hass.async_create_task( discovery.async_load_platform( @@ -80,10 +88,13 @@ def _discover_platform(self, component): ) @callback - def handle_data(self, command, msg): + def handle_data( + self, command: int, msg: list[dict[str, Any]] | dict[str, Any] + ) -> None: """Handle changes to the mailbox.""" if command == CMD_MESSAGE_LIST: + msg = cast(list[dict[str, Any]], msg) _LOGGER.debug("AsteriskVM sent updated message list: Len %d", len(msg)) old_messages = self.messages self.messages = sorted( @@ -93,6 +104,7 @@ def handle_data(self, command, msg): async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM, DOMAIN) async_dispatcher_send(self.hass, SIGNAL_MESSAGE_UPDATE, self.messages) elif command == CMD_MESSAGE_CDR: + msg = cast(dict[str, Any], msg) _LOGGER.debug( "AsteriskVM sent updated CDR list: Len %d", len(msg.get("entries", [])) ) @@ -112,13 +124,13 @@ def handle_data(self, command, msg): ) @callback - def _request_messages(self): + def _request_messages(self) -> None: """Handle changes to the mailbox.""" _LOGGER.debug("Requesting message list") self.client.messages() @callback - def _request_cdr(self): + def _request_cdr(self) -> None: """Handle changes to the CDR.""" _LOGGER.debug("Requesting CDR list") self.client.get_cdr() diff --git a/homeassistant/components/asterisk_mbox/mailbox.py b/homeassistant/components/asterisk_mbox/mailbox.py index edf95cb3787478..95b3b7e3b15c8f 100644 --- a/homeassistant/components/asterisk_mbox/mailbox.py +++ b/homeassistant/components/asterisk_mbox/mailbox.py @@ -74,7 +74,7 @@ async def async_get_media(self, msgid: str) -> bytes: async def async_get_messages(self) -> list[dict[str, Any]]: """Return a list of the current messages.""" data: AsteriskData = self.hass.data[ASTERISK_DOMAIN] - return data.messages + return data.messages or [] async def async_delete(self, msgid: str) -> bool: """Delete the specified messages.""" diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 9e6da0ea8f779c..53a0b5d06b54fa 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -3,10 +3,14 @@ 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 @@ 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,11 +34,14 @@ DEFAULT_INTERFACE, KEY_METHOD, KEY_SENSORS, + PROTOCOL_HTTP, + PROTOCOL_HTTPS, PROTOCOL_TELNET, SENSORS_BYTES, SENSORS_LOAD_AVG, SENSORS_RATES, SENSORS_TEMPERATURES, + SENSORS_TEMPERATURES_LEGACY, ) SENSORS_TYPE_BYTES = "sensors_bytes" @@ -47,9 +55,42 @@ _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] | tuple[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, tuple)): + raise UpdateFailed("Received invalid data type") + return dict(zip(keys, data)) + + return _wrapper + + return _handle_errors_and_zip class AsusWrtBridge(ABC): @@ -60,6 +101,9 @@ def get_bridge( 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: @@ -234,40 +278,137 @@ async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: async def _get_available_temperature_sensors(self) -> list[str]: """Check which temperature information is available on the router.""" availability = await self._api.async_find_temperature_commands() - return [SENSORS_TEMPERATURES[i] for i in range(3) if availability[i]] + return [SENSORS_TEMPERATURES_LEGACY[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.""" + return await self._api.async_get_loadavg() + + @handle_errors_and_zip((OSError, ValueError), None) + async def _get_temperatures(self) -> Any: + """Fetch temperatures information from the router.""" + return await self._api.async_get_temperature() + + +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: - avg = await self._api.async_get_loadavg() - except (IndexError, OSError, ValueError) as exc: + 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() + } - return _get_dict(SENSORS_LOAD_AVG, avg) + 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_temperatures(self) -> dict[str, Any]: - """Fetch temperatures information from the router.""" + async def _get_available_temperature_sensors(self) -> list[str]: + """Check which temperature information is available on the router.""" try: - temperatures: dict[str, Any] = await self._api.async_get_temperature() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc + 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() - return temperatures + @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 56569d4f23bc5a..047e9b549d8221 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -7,6 +7,7 @@ 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.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( + CONF_BASE, CONF_HOST, CONF_MODE, CONF_PASSWORD, @@ -30,6 +32,7 @@ SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .bridge import AsusWrtBridge from .const import ( @@ -44,11 +47,21 @@ 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 @@ _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 @@ async def _async_check_connection( ) -> 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 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 not api.is_connected: - _LOGGER.error("Error connecting to the AsusWrt router at %s", host) - return RESULT_CONN_ERROR, None + 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 @@ async def async_step_user( 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 1733d4c09c3765..a60046b50c2a91 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -20,6 +20,8 @@ MODE_AP = "ap" MODE_ROUTER = "router" +PROTOCOL_HTTP = "http" +PROTOCOL_HTTPS = "https" PROTOCOL_SSH = "ssh" PROTOCOL_TELNET = "telnet" @@ -28,4 +30,5 @@ SENSORS_CONNECTED_DEVICE = ["sensor_connected_device"] SENSORS_LOAD_AVG = ["sensor_load_avg1", "sensor_load_avg5", "sensor_load_avg15"] SENSORS_RATES = ["sensor_rx_rates", "sensor_tx_rates"] -SENSORS_TEMPERATURES = ["2.4GHz", "5.0GHz", "CPU"] +SENSORS_TEMPERATURES_LEGACY = ["2.4GHz", "5.0GHz", "CPU"] +SENSORS_TEMPERATURES = [*SENSORS_TEMPERATURES_LEGACY, "5.0GHz_2", "6.0GHz"] diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 39f88fb96fe17a..f4b2e3386e9a27 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.21"] } diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index c6fe651d292afd..927eef572f7186 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -6,6 +6,8 @@ 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 @@ async def setup(self) -> None: """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/sensor.py b/homeassistant/components/asuswrt/sensor.py index 4f9ec0af411c5a..f1296befbbad7d 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -38,7 +38,7 @@ from .router import AsusWrtRouter -@dataclass +@dataclass(frozen=True) class AsusWrtSensorEntityDescription(SensorEntityDescription): """A class that describes AsusWrt sensor entities.""" @@ -156,6 +156,26 @@ class AsusWrtSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, suggested_display_precision=1, ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[3], + translation_key="5ghz_2_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[4], + translation_key="6ghz_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + ), ) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index 52b9f919434381..4c8386dcd0042d 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", @@ -77,6 +82,22 @@ }, "cpu_temperature": { "name": "CPU Temperature" + }, + "5ghz_2_temperature": { + "name": "5GHz Temperature (Radio 2)" + }, + "6ghz_temperature": { + "name": "6GHz Temperature" + } + } + }, + "selector": { + "protocols": { + "options": { + "https": "HTTPS", + "http": "HTTP", + "ssh": "SSH", + "telnet": "Telnet" } } } diff --git a/homeassistant/components/atag/config_flow.py b/homeassistant/components/atag/config_flow.py index ecebec717f45d8..8dd7020acfba77 100644 --- a/homeassistant/components/atag/config_flow.py +++ b/homeassistant/components/atag/config_flow.py @@ -1,9 +1,12 @@ """Config flow for the Atag component.""" +from typing import Any + import pyatag import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import DOMAIN @@ -19,7 +22,9 @@ class AtagConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if not user_input: @@ -39,7 +44,7 @@ async def async_step_user(self, user_input=None): return self.async_create_entry(title=atag.id, data=user_input) - async def _show_form(self, errors=None): + async def _show_form(self, errors: dict[str, str] | None = None) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id="user", diff --git a/homeassistant/components/atag/strings.json b/homeassistant/components/atag/strings.json index 39ed972524dfc1..82070c0209fe09 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/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index b19a9833a47a7f..144666844e7288 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -105,12 +105,12 @@ def _native_datetime() -> datetime: return datetime.now() -@dataclass +@dataclass(frozen=True) class AugustBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes August binary_sensor entity.""" -@dataclass +@dataclass(frozen=True) class AugustDoorbellRequiredKeysMixin: """Mixin for required keys.""" @@ -118,7 +118,7 @@ class AugustDoorbellRequiredKeysMixin: is_time_based: bool -@dataclass +@dataclass(frozen=True) class AugustDoorbellBinarySensorEntityDescription( BinarySensorEntityDescription, AugustDoorbellRequiredKeysMixin ): diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 0028db55415712..f22b16008d3a57 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -80,20 +80,24 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Store an AugustGateway().""" self._august_gateway: AugustGateway | None = None self._aiohttp_session: aiohttp.ClientSession | None = None self._user_auth_details: dict[str, Any] = {} self._needs_reset = True - self._mode = None + self._mode: str | None = None super().__init__() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" return await self.async_step_user_validate() - async def async_step_user_validate(self, user_input=None): + async def async_step_user_validate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle authentication.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} @@ -177,7 +181,9 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: self._needs_reset = True return await self.async_step_reauth_validate() - async def async_step_reauth_validate(self, user_input=None): + async def async_step_reauth_validate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle reauth and validation.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index aacebb4bb5c552..d0f2a27522dace 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.2"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.4.0"] } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 7f6e0c51995552..1896a91c54f45c 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -63,14 +63,14 @@ def _retrieve_linked_keypad_battery_state(detail: KeypadDetail) -> int | None: _T = TypeVar("_T", LockDetail, KeypadDetail) -@dataclass +@dataclass(frozen=True) class AugustRequiredKeysMixin(Generic[_T]): """Mixin for required keys.""" value_fn: Callable[[_T], int | None] -@dataclass +@dataclass(frozen=True) class AugustSensorEntityDescription( SensorEntityDescription, AugustRequiredKeysMixin[_T] ): diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index 8fa4b28575882b..95e66ff226e42b 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from aiohttp import ClientError from auroranoaa import AuroraForecast @@ -10,6 +11,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -45,7 +47,9 @@ def async_get_options_flow( """Get the options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index b5dc236dfa24e7..39abba4ada5a87 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,60 @@ 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) + [alarm, *_] = self.client.alarms() + 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) + data["alarm"] = alarm + 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 e9ca9e4712190d..00000000000000 --- 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 3711dd6d800989..d1266a838c33cc 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 @@ 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 55f3be5d6dbc83..80b0fd656b6770 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -5,7 +5,7 @@ import logging from typing import Any -from aurorapy.client import AuroraError, AuroraSerialClient, AuroraTimeoutError +from aurorapy.mapping import Mapping as AuroraMapping from homeassistant.components.sensor import ( SensorDeviceClass, @@ -21,14 +21,33 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .aurora_device import AuroraEntity -from .const import DOMAIN +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +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__) +ALARM_STATES = list(AuroraMapping.ALARM_STATES.values()) SENSOR_TYPES = [ + SensorEntityDescription( + key="alarm", + device_class=SensorDeviceClass.ENUM, + options=ALARM_STATES, + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="alarm", + ), SensorEntityDescription( key="instantaneouspower", device_class=SensorDeviceClass.POWER, @@ -61,70 +80,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 - - 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() + 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], + ) + + @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/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index 50b6e0db502d42..63ea1cfefd4727 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -21,11 +21,14 @@ }, "entity": { "sensor": { + "alarm": { + "name": "Alarm status" + }, "power_output": { - "name": "Power Output" + "name": "Power output" }, "total_energy": { - "name": "Total Energy" + "name": "Total energy" } } } diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index aff232f2934e21..efc8ae99ef94ca 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -23,7 +23,7 @@ from .const import DOMAIN, SERVICE_ID -@dataclass +@dataclass(frozen=True) class SensorValueEntityDescription(SensorEntityDescription): """Class describing Aussie Broadband sensor entities.""" diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index e0cc0eeb1ec14a..9b96e57dbd38c8 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -71,14 +71,14 @@ 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,15 @@ 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 . import indieauth if TYPE_CHECKING: + from homeassistant.auth.providers.trusted_networks import ( + TrustedNetworksAuthProvider, + ) + from . import StoreResultType @@ -146,12 +151,41 @@ async def get(self, request: web.Request) -> web.Response: 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: + 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 + + providers.append( + { + "name": provider.name, + "id": provider.id, + "type": provider.type, + } + ) + + return self.json(providers) def _prepare_result_json( diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index d386bb7a48889f..0dd3ee64cdf70d 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/__init__.py b/homeassistant/components/automation/__init__.py index 84f7f3aca52b7d..4e6fa477ed2bac 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Callable, Mapping from dataclasses import dataclass +from functools import partial import logging from typing import Any, Protocol, cast @@ -55,6 +56,11 @@ ) from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -130,9 +136,20 @@ def __call__(self, variables: Mapping[str, Any] | None = None) -> bool: # AutomationActionType, AutomationTriggerData, # and AutomationTriggerInfo are deprecated as of 2022.9. -AutomationActionType = TriggerActionType -AutomationTriggerData = TriggerData -AutomationTriggerInfo = TriggerInfo +# Can be removed in 2025.1 +_DEPRECATED_AutomationActionType = DeprecatedConstant( + TriggerActionType, "TriggerActionType", "2025.1" +) +_DEPRECATED_AutomationTriggerData = DeprecatedConstant( + TriggerData, "TriggerData", "2025.1" +) +_DEPRECATED_AutomationTriggerInfo = DeprecatedConstant( + TriggerInfo, "TriggerInfo", "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) @bind_hass diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index ed801772e6dd76..ff0fe43ea26ad7 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -11,7 +11,7 @@ from homeassistant.components import blueprint from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_CONDITION, @@ -21,7 +21,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv, script +from homeassistant.helpers import config_validation as cv, script from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/automation/helpers.py b/homeassistant/components/automation/helpers.py index 7c2efc17bf49e7..a7c329a544a194 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/awair/sensor.py b/homeassistant/components/awair/sensor.py index 2a09a8d4e70378..698850d6a49bd4 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -50,14 +50,14 @@ DUST_ALIASES = [API_PM25, API_PM10] -@dataclass +@dataclass(frozen=True) class AwairRequiredKeysMixin: """Mixin for required keys.""" unique_id_tag: str -@dataclass +@dataclass(frozen=True) class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): """Describes Awair sensor entity.""" diff --git a/homeassistant/components/aws/config_flow.py b/homeassistant/components/aws/config_flow.py index 1854afc623140d..e0829ef29149f8 100644 --- a/homeassistant/components/aws/config_flow.py +++ b/homeassistant/components/aws/config_flow.py @@ -1,6 +1,10 @@ """Config flow for AWS component.""" +from collections.abc import Mapping +from typing import Any + from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN @@ -10,7 +14,7 @@ class AWSFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: Mapping[str, Any]) -> FlowResult: """Import a config entry.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index 4cc81947e2703b..d68de7742dc7c7 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from datetime import timedelta +from datetime import datetime, timedelta from axis.models.event import Event, EventGroup, EventOperation, EventTopic @@ -81,7 +81,7 @@ def async_event_callback(self, event: Event) -> None: self._attr_is_on = event.is_tripped @callback - def scheduled_update(now): + def scheduled_update(now: datetime) -> None: """Timer callback for sensor update.""" self.cancel_scheduled_update = None self.async_write_ha_state() diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 0c132814e3946d..67ef61af8ac7b1 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -24,7 +24,7 @@ CONF_TRIGGER_TIME, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -65,80 +65,86 @@ def __init__( self.additional_diagnostics: dict[str, Any] = {} @property - def host(self): + def host(self) -> str: """Return the host address of this device.""" - return self.config_entry.data[CONF_HOST] + host: str = self.config_entry.data[CONF_HOST] + return host @property - def port(self): + def port(self) -> int: """Return the HTTP port of this device.""" - return self.config_entry.data[CONF_PORT] + port: int = self.config_entry.data[CONF_PORT] + return port @property - def username(self): + def username(self) -> str: """Return the username of this device.""" - return self.config_entry.data[CONF_USERNAME] + username: str = self.config_entry.data[CONF_USERNAME] + return username @property - def password(self): + def password(self) -> str: """Return the password of this device.""" - return self.config_entry.data[CONF_PASSWORD] + password: str = self.config_entry.data[CONF_PASSWORD] + return password @property - def model(self): + def model(self) -> str: """Return the model of this device.""" - return self.config_entry.data[CONF_MODEL] + model: str = self.config_entry.data[CONF_MODEL] + return model @property - def name(self): + def name(self) -> str: """Return the name of this device.""" - return self.config_entry.data[CONF_NAME] + name: str = self.config_entry.data[CONF_NAME] + return name @property - def unique_id(self): + def unique_id(self) -> str | None: """Return the unique ID (serial number) of this device.""" return self.config_entry.unique_id # Options @property - def option_events(self): + def option_events(self) -> bool: """Config entry option defining if platforms based on events should be created.""" return self.config_entry.options.get(CONF_EVENTS, DEFAULT_EVENTS) @property - def option_stream_profile(self): + def option_stream_profile(self) -> str: """Config entry option defining what stream profile camera platform should use.""" return self.config_entry.options.get( CONF_STREAM_PROFILE, DEFAULT_STREAM_PROFILE ) @property - def option_trigger_time(self): + def option_trigger_time(self) -> int: """Config entry option defining minimum number of seconds to keep trigger high.""" return self.config_entry.options.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME) @property - def option_video_source(self): + def option_video_source(self) -> str: """Config entry option defining what video source camera platform should use.""" return self.config_entry.options.get(CONF_VIDEO_SOURCE, DEFAULT_VIDEO_SOURCE) # Signals @property - def signal_reachable(self): + def signal_reachable(self) -> str: """Device specific event to signal a change in connection status.""" return f"axis_reachable_{self.unique_id}" @property - def signal_new_address(self): + def signal_new_address(self) -> str: """Device specific event to signal a change in device address.""" return f"axis_new_address_{self.unique_id}" # Callbacks @callback - def async_connection_status_callback(self, status): + def async_connection_status_callback(self, status: Signal) -> None: """Handle signals of device connection status. This is called on every RTSP keep-alive message. @@ -169,8 +175,8 @@ async def async_update_device_registry(self) -> None: device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, configuration_url=self.api.config.url, - connections={(CONNECTION_NETWORK_MAC, self.unique_id)}, - identifiers={(AXIS_DOMAIN, self.unique_id)}, + connections={(CONNECTION_NETWORK_MAC, self.unique_id)}, # type: ignore[arg-type] + identifiers={(AXIS_DOMAIN, self.unique_id)}, # type: ignore[arg-type] manufacturer=ATTR_MANUFACTURER, model=f"{self.model} {self.product_type}", name=self.name, @@ -202,7 +208,7 @@ def mqtt_message(self, message: ReceiveMessage) -> None: # Setup and teardown methods - def async_setup_events(self): + def async_setup_events(self) -> None: """Set up the device events.""" if self.option_events: @@ -222,7 +228,7 @@ def disconnect_from_stream(self) -> None: self.api.stream.connection_status_callback.clear() self.api.stream.stop() - async def shutdown(self, event) -> None: + async def shutdown(self, event: Event) -> None: """Stop the event stream.""" self.disconnect_from_stream() diff --git a/homeassistant/components/axis/entity.py b/homeassistant/components/axis/entity.py index 5a1fede53c7e6c..81f0b1678fb476 100644 --- a/homeassistant/components/axis/entity.py +++ b/homeassistant/components/axis/entity.py @@ -42,7 +42,7 @@ def __init__(self, device: AxisNetworkDevice) -> None: self.device = device self._attr_device_info = DeviceInfo( - identifiers={(AXIS_DOMAIN, device.unique_id)}, + identifiers={(AXIS_DOMAIN, device.unique_id)}, # type: ignore[arg-type] serial_number=device.unique_id, ) diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 47a25b542a72ff..8c302dba2012be 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/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index dc3d0e5b04b709..edd06d69d2e80b 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -32,7 +32,7 @@ BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" -@dataclass +@dataclass(frozen=True) class AzureDevOpsEntityDescription(EntityDescription): """Class describing Azure DevOps entities.""" diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index ac884f73d68207..6daf9b434df4f7 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -17,14 +17,14 @@ from .const import CONF_ORG, DOMAIN -@dataclass +@dataclass(frozen=True) class AzureDevOpsSensorEntityDescriptionMixin: """Mixin class for required Azure DevOps sensor description keys.""" build_key: int -@dataclass +@dataclass(frozen=True) class AzureDevOpsSensorEntityDescription( AzureDevOpsEntityDescription, SensorEntityDescription, diff --git a/homeassistant/components/baf/binary_sensor.py b/homeassistant/components/baf/binary_sensor.py index a68e80c3ac23e2..50e8cd78629a15 100644 --- a/homeassistant/components/baf/binary_sensor.py +++ b/homeassistant/components/baf/binary_sensor.py @@ -21,14 +21,14 @@ from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFBinarySensorDescriptionMixin: """Required values for BAF binary sensors.""" value_fn: Callable[[Device], bool | None] -@dataclass +@dataclass(frozen=True) class BAFBinarySensorDescription( BinarySensorEntityDescription, BAFBinarySensorDescriptionMixin, diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index 059603fc589393..e2d1c5fcb3a03a 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -93,8 +93,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: 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/baf/number.py b/homeassistant/components/baf/number.py index 7fd1c9ed290842..9dd4180c7e1575 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -22,14 +22,14 @@ from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFNumberDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], int | None] -@dataclass +@dataclass(frozen=True) class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): """Class describing BAF sensor entities.""" diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py index d811180414211f..5c8d8f2979b386 100644 --- a/homeassistant/components/baf/sensor.py +++ b/homeassistant/components/baf/sensor.py @@ -28,14 +28,14 @@ from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFSensorDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], int | float | str | None] -@dataclass +@dataclass(frozen=True) class BAFSensorDescription( SensorEntityDescription, BAFSensorDescriptionMixin, diff --git a/homeassistant/components/baf/switch.py b/homeassistant/components/baf/switch.py index ed4e635ece3459..ccb8aee36e5947 100644 --- a/homeassistant/components/baf/switch.py +++ b/homeassistant/components/baf/switch.py @@ -18,14 +18,14 @@ from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFSwitchDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], bool | None] -@dataclass +@dataclass(frozen=True) class BAFSwitchDescription( SwitchEntityDescription, BAFSwitchDescriptionMixin, diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index 9f363746a8fcdb..ec7a9fe484a26b 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class BalboaBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -41,37 +41,33 @@ class BalboaBinarySensorEntityDescriptionMixin: on_off_icons: tuple[str, str] -@dataclass +@dataclass(frozen=True) class BalboaBinarySensorEntityDescription( BinarySensorEntityDescription, BalboaBinarySensorEntityDescriptionMixin ): """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 @@ def __init__( 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 0d0fa9bd179ccb..d213a8fd2e8cb7 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 3b4f7d08fff2f6..e02579658da7d8 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 214ccf8fbe1d50..e0af12514da37f 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 Wi-Fi 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 a84cbc18756058..3a32a1afb57c51 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -1,11 +1,11 @@ """Component to interface with binary sensors.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum +from functools import partial import logging -from typing import Literal, final +from typing import TYPE_CHECKING, Literal, final import voluptuous as vol @@ -17,12 +17,23 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) + DOMAIN = "binary_sensor" SCAN_INTERVAL = timedelta(seconds=30) @@ -122,34 +133,94 @@ class BinarySensorDeviceClass(StrEnum): # DEVICE_CLASS* below are deprecated as of 2021.12 # use the BinarySensorDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in BinarySensorDeviceClass] -DEVICE_CLASS_BATTERY = BinarySensorDeviceClass.BATTERY.value -DEVICE_CLASS_BATTERY_CHARGING = BinarySensorDeviceClass.BATTERY_CHARGING.value -DEVICE_CLASS_CO = BinarySensorDeviceClass.CO.value -DEVICE_CLASS_COLD = BinarySensorDeviceClass.COLD.value -DEVICE_CLASS_CONNECTIVITY = BinarySensorDeviceClass.CONNECTIVITY.value -DEVICE_CLASS_DOOR = BinarySensorDeviceClass.DOOR.value -DEVICE_CLASS_GARAGE_DOOR = BinarySensorDeviceClass.GARAGE_DOOR.value -DEVICE_CLASS_GAS = BinarySensorDeviceClass.GAS.value -DEVICE_CLASS_HEAT = BinarySensorDeviceClass.HEAT.value -DEVICE_CLASS_LIGHT = BinarySensorDeviceClass.LIGHT.value -DEVICE_CLASS_LOCK = BinarySensorDeviceClass.LOCK.value -DEVICE_CLASS_MOISTURE = BinarySensorDeviceClass.MOISTURE.value -DEVICE_CLASS_MOTION = BinarySensorDeviceClass.MOTION.value -DEVICE_CLASS_MOVING = BinarySensorDeviceClass.MOVING.value -DEVICE_CLASS_OCCUPANCY = BinarySensorDeviceClass.OCCUPANCY.value -DEVICE_CLASS_OPENING = BinarySensorDeviceClass.OPENING.value -DEVICE_CLASS_PLUG = BinarySensorDeviceClass.PLUG.value -DEVICE_CLASS_POWER = BinarySensorDeviceClass.POWER.value -DEVICE_CLASS_PRESENCE = BinarySensorDeviceClass.PRESENCE.value -DEVICE_CLASS_PROBLEM = BinarySensorDeviceClass.PROBLEM.value -DEVICE_CLASS_RUNNING = BinarySensorDeviceClass.RUNNING.value -DEVICE_CLASS_SAFETY = BinarySensorDeviceClass.SAFETY.value -DEVICE_CLASS_SMOKE = BinarySensorDeviceClass.SMOKE.value -DEVICE_CLASS_SOUND = BinarySensorDeviceClass.SOUND.value -DEVICE_CLASS_TAMPER = BinarySensorDeviceClass.TAMPER.value -DEVICE_CLASS_UPDATE = BinarySensorDeviceClass.UPDATE.value -DEVICE_CLASS_VIBRATION = BinarySensorDeviceClass.VIBRATION.value -DEVICE_CLASS_WINDOW = BinarySensorDeviceClass.WINDOW.value +_DEPRECATED_DEVICE_CLASS_BATTERY = DeprecatedConstantEnum( + BinarySensorDeviceClass.BATTERY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_BATTERY_CHARGING = DeprecatedConstantEnum( + BinarySensorDeviceClass.BATTERY_CHARGING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CO = DeprecatedConstantEnum( + BinarySensorDeviceClass.CO, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_COLD = DeprecatedConstantEnum( + BinarySensorDeviceClass.COLD, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CONNECTIVITY = DeprecatedConstantEnum( + BinarySensorDeviceClass.CONNECTIVITY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum( + BinarySensorDeviceClass.DOOR, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GARAGE_DOOR = DeprecatedConstantEnum( + BinarySensorDeviceClass.GARAGE_DOOR, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GAS = DeprecatedConstantEnum( + BinarySensorDeviceClass.GAS, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_HEAT = DeprecatedConstantEnum( + BinarySensorDeviceClass.HEAT, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_LIGHT = DeprecatedConstantEnum( + BinarySensorDeviceClass.LIGHT, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_LOCK = DeprecatedConstantEnum( + BinarySensorDeviceClass.LOCK, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOISTURE = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOISTURE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOTION = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOTION, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOVING = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOVING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_OCCUPANCY = DeprecatedConstantEnum( + BinarySensorDeviceClass.OCCUPANCY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_OPENING = DeprecatedConstantEnum( + BinarySensorDeviceClass.OPENING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PLUG = DeprecatedConstantEnum( + BinarySensorDeviceClass.PLUG, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_POWER = DeprecatedConstantEnum( + BinarySensorDeviceClass.POWER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PRESENCE = DeprecatedConstantEnum( + BinarySensorDeviceClass.PRESENCE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PROBLEM = DeprecatedConstantEnum( + BinarySensorDeviceClass.PROBLEM, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_RUNNING = DeprecatedConstantEnum( + BinarySensorDeviceClass.RUNNING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SAFETY = DeprecatedConstantEnum( + BinarySensorDeviceClass.SAFETY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SMOKE = DeprecatedConstantEnum( + BinarySensorDeviceClass.SMOKE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SOUND = DeprecatedConstantEnum( + BinarySensorDeviceClass.SOUND, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_TAMPER = DeprecatedConstantEnum( + BinarySensorDeviceClass.TAMPER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_UPDATE = DeprecatedConstantEnum( + BinarySensorDeviceClass.UPDATE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_VIBRATION = DeprecatedConstantEnum( + BinarySensorDeviceClass.VIBRATION, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( + BinarySensorDeviceClass.WINDOW, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) # mypy: disallow-any-generics @@ -176,14 +247,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class BinarySensorEntityDescription(EntityDescription): +class BinarySensorEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes binary sensor entities.""" device_class: BinarySensorDeviceClass | None = None -class BinarySensorEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "is_on", +} + + +class BinarySensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Represent a binary sensor.""" entity_description: BinarySensorEntityDescription @@ -206,7 +282,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @property + @cached_property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -215,7 +291,7 @@ def device_class(self) -> BinarySensorDeviceClass | None: return self.entity_description.device_class return None - @property + @cached_property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self._attr_is_on diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index 31d1f6162d7d18..977e704eb981a2 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -112,7 +112,7 @@ async def async_step_zeroconf( 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 c6413dd4372d4f..d83c2686563b45 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -21,17 +21,11 @@ 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 @@ {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 bf45ae7a5820ab..8e0750d1373fb4 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -104,4 +104,3 @@ async def async_alarm_arm_away(self, code: str | None = None) -> None: raise HomeAssistantError("Blink failed to arm camera away") from er await self.coordinator.async_refresh() - self.async_write_ha_state() diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 9400e79838b14e..b2a23b0aa31692 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -32,9 +32,11 @@ device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), + # Camera Armed sensor is deprecated 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, diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index f507364f17f33e..4d05aea88a5d8f 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -8,17 +8,26 @@ from typing import Any from requests.exceptions import ChunkedEncodingError +import voluptuous as vol from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv 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, SERVICE_TRIGGER +from .const import ( + DEFAULT_BRAND, + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_VIDEO, + SERVICE_TRIGGER, +) from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,6 +52,16 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") + platform.async_register_entity_service( + SERVICE_SAVE_RECENT_CLIPS, + {vol.Required(CONF_FILE_PATH): cv.string}, + "save_recent_clips", + ) + platform.async_register_entity_service( + SERVICE_SAVE_VIDEO, + {vol.Required(CONF_FILENAME): cv.string}, + "save_video", + ) class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): @@ -64,7 +83,7 @@ def __init__(self, coordinator: BlinkUpdateCoordinator, name, camera) -> None: manufacturer=DEFAULT_BRAND, model=camera.camera_type, ) - _LOGGER.debug("Initialized blink camera %s", self.name) + _LOGGER.debug("Initialized blink camera %s", self._camera.name) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -121,3 +140,39 @@ def camera_image( except TypeError: _LOGGER.debug("No cached image for %s", self._camera.name) return None + + async def save_recent_clips(self, file_path) -> None: + """Save multiple recent clips to output directory.""" + if not self.hass.config.is_allowed_path(file_path): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": file_path}, + ) + + try: + await self._camera.save_recent_clips(output_dir=file_path) + except OSError as err: + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err + + async def save_video(self, filename) -> None: + """Handle save video service calls.""" + if not self.hass.config.is_allowed_path(filename): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": filename}, + ) + + try: + await self._camera.video_to_file(filename) + except OSError as err: + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 7de42a80efcd6b..7aa3d0d388ec14 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -24,10 +24,12 @@ SERVICE_SAVE_VIDEO = "save_video" SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" SERVICE_SEND_PIN = "send_pin" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py index d3f7551e1b248e..d53d23c4344b88 100644 --- a/homeassistant/components/blink/coordinator.py +++ b/homeassistant/components/blink/coordinator.py @@ -13,6 +13,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = 30 class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -25,7 +26,7 @@ def __init__(self, hass: HomeAssistant, api: Blink) -> None: 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 00000000000000..664d1421ac2be1 --- /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", "unique_id"} + + +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 bb8fd4a5a5126b..a12689190525e2 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": [ { @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.22.3"] + "requirements": ["blinkpy==0.22.4"] } diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py new file mode 100644 index 00000000000000..5c034cdb7c59e9 --- /dev/null +++ b/homeassistant/components/blink/services.py @@ -0,0 +1,124 @@ +"""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_PIN +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + issue_registry as ir, +) + +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_REFRESH, SERVICE_SEND_PIN +from .coordinator import BlinkUpdateCoordinator + +SERVICE_UPDATE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + } +) +SERVICE_SEND_PIN_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_PIN): 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 send_pin(call: ServiceCall): + """Call blink to send new pin.""" + for entry_id in call.data[ATTR_CONFIG_ENTRY_ID]: + if not (config_entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if config_entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + coordinator = hass.data[DOMAIN][entry_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.""" + ir.async_create_issue( + hass, + DOMAIN, + "service_deprecation", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation", + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): + await coordinator.api.refresh(force_cache=True) + + # Register all the above services + # Refresh service is deprecated and will be removed in 7/2024 + service_mapping = [ + (blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_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/services.yaml b/homeassistant/components/blink/services.yaml index 95f4d33f91f732..87083a990ef9cb 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -1,6 +1,13 @@ # Describes the format for available Blink services blink_update: + fields: + device_id: + required: true + selector: + device: + integration: blink + trigger_camera: target: entity: @@ -8,12 +15,11 @@ trigger_camera: domain: camera save_video: + target: + entity: + integration: blink + domain: camera fields: - name: - required: true - example: "Living Room" - selector: - text: filename: required: true example: "/tmp/video.mp4" @@ -21,12 +27,11 @@ save_video: text: save_recent_clips: + target: + entity: + integration: blink + domain: camera fields: - name: - required: true - example: "Living Room" - selector: - text: file_path: required: true example: "/tmp" @@ -35,6 +40,11 @@ save_recent_clips: send_pin: fields: + config_entry_id: + required: true + selector: + config_entry: + integration: blink pin: example: "abc123" selector: diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 85556bbcd5a132..a875fb3e343fe7 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -47,12 +47,23 @@ "camera_armed": { "name": "Camera armed" } + }, + "switch": { + "camera_motion": { + "name": "Camera motion detection" + } } }, "services": { "blink_update": { "name": "Update", - "description": "Forces a refresh." + "description": "Forces a refresh.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "The Blink device id." + } + } }, "trigger_camera": { "name": "Trigger camera", @@ -62,10 +73,6 @@ "name": "Save video", "description": "Saves last recorded video clip to local file.", "fields": { - "name": { - "name": "[%key:common::config_flow::data::name%]", - "description": "Name of camera to grab video from." - }, "filename": { "name": "File name", "description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)." @@ -76,10 +83,6 @@ "name": "Save recent clips", "description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_{name}.mp4\".", "fields": { - "name": { - "name": "[%key:common::config_flow::data::name%]", - "description": "Name of camera to grab recent clips from." - }, "file_path": { "name": "Output directory", "description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)." @@ -93,6 +96,37 @@ "pin": { "name": "Pin", "description": "PIN received from blink. Leave empty if you only received a verification email." + }, + "config_entry_id": { + "name": "Integration ID", + "description": "The Blink Integration id." + } + } + } + }, + "exceptions": { + "integration_not_found": { + "message": "Integration '{target}' not found in 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" + } + }, + "issues": { + "service_deprecation": { + "title": "Blink update service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::blink::issues::service_deprecation::title%]", + "description": "Blink update service is deprecated and will be removed.\nPlease update your automations and scripts to use `Home Assistant Core Integration: Update entity`." + } } } } diff --git a/homeassistant/components/blink/switch.py b/homeassistant/components/blink/switch.py new file mode 100644 index 00000000000000..197c8e086856ee --- /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/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py new file mode 100644 index 00000000000000..604f251bfeb6b7 --- /dev/null +++ b/homeassistant/components/blue_current/__init__.py @@ -0,0 +1,177 @@ +"""The Blue Current integration.""" +from __future__ import annotations + +from contextlib import suppress +from datetime import datetime +from typing import Any + +from bluecurrent_api import Client +from bluecurrent_api.exceptions import ( + BlueCurrentException, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later + +from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE + +PLATFORMS = [Platform.SENSOR] +CHARGE_POINTS = "CHARGE_POINTS" +DATA = "data" +SMALL_DELAY = 1 +LARGE_DELAY = 20 + +GRID = "GRID" +OBJECT = "object" +VALUE_TYPES = ["CH_STATUS"] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Blue Current as a config entry.""" + hass.data.setdefault(DOMAIN, {}) + client = Client() + api_token = config_entry.data[CONF_API_TOKEN] + connector = Connector(hass, config_entry, client) + + try: + await connector.connect(api_token) + except InvalidApiToken as err: + raise ConfigEntryAuthFailed("Invalid API token.") from err + except BlueCurrentException as err: + raise ConfigEntryNotReady from err + + hass.async_create_task(connector.start_loop()) + await client.get_charge_points() + + await client.wait_for_response() + hass.data[DOMAIN][config_entry.entry_id] = connector + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + config_entry.async_on_unload(connector.disconnect) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload the Blue Current config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +class Connector: + """Define a class that connects to the Blue Current websocket API.""" + + def __init__( + self, hass: HomeAssistant, config: ConfigEntry, client: Client + ) -> None: + """Initialize.""" + self.config: ConfigEntry = config + self.hass: HomeAssistant = hass + self.client: Client = client + self.charge_points: dict[str, dict] = {} + self.grid: dict[str, Any] = {} + self.available = False + + async def connect(self, token: str) -> None: + """Register on_data and connect to the websocket.""" + await self.client.connect(token) + self.available = True + + async def on_data(self, message: dict) -> None: + """Handle received data.""" + + async def handle_charge_points(data: list) -> None: + """Loop over the charge points and get their data.""" + for entry in data: + evse_id = entry[EVSE_ID] + model = entry[MODEL_TYPE] + name = entry[ATTR_NAME] + self.add_charge_point(evse_id, model, name) + await self.get_charge_point_data(evse_id) + await self.client.get_grid_status(data[0][EVSE_ID]) + + object_name: str = message[OBJECT] + + # gets charge point ids + if object_name == CHARGE_POINTS: + charge_points_data: list = message[DATA] + await handle_charge_points(charge_points_data) + + # gets charge point key / values + elif object_name in VALUE_TYPES: + value_data: dict = message[DATA] + evse_id = value_data.pop(EVSE_ID) + self.update_charge_point(evse_id, value_data) + + # gets grid key / values + elif GRID in object_name: + data: dict = message[DATA] + self.grid = data + self.dispatch_grid_update_signal() + + async def get_charge_point_data(self, evse_id: str) -> None: + """Get all the data of a charge point.""" + await self.client.get_status(evse_id) + + def add_charge_point(self, evse_id: str, model: str, name: str) -> None: + """Add a charge point to charge_points.""" + self.charge_points[evse_id] = {MODEL_TYPE: model, ATTR_NAME: name} + + def update_charge_point(self, evse_id: str, data: dict) -> None: + """Update the charge point data.""" + self.charge_points[evse_id].update(data) + self.dispatch_value_update_signal(evse_id) + + def dispatch_value_update_signal(self, evse_id: str) -> None: + """Dispatch a value signal.""" + async_dispatcher_send(self.hass, f"{DOMAIN}_value_update_{evse_id}") + + def dispatch_grid_update_signal(self) -> None: + """Dispatch a grid signal.""" + async_dispatcher_send(self.hass, f"{DOMAIN}_grid_update") + + async def start_loop(self) -> None: + """Start the receive loop.""" + try: + await self.client.start_loop(self.on_data) + except BlueCurrentException as err: + LOGGER.warning( + "Disconnected from the Blue Current websocket. Retrying to connect in background. %s", + err, + ) + + async_call_later(self.hass, SMALL_DELAY, self.reconnect) + + async def reconnect(self, _event_time: datetime | None = None) -> None: + """Keep trying to reconnect to the websocket.""" + try: + await self.connect(self.config.data[CONF_API_TOKEN]) + LOGGER.info("Reconnected to the Blue Current websocket") + self.hass.async_create_task(self.start_loop()) + await self.client.get_charge_points() + except RequestLimitReached: + self.available = False + async_call_later( + self.hass, self.client.get_next_reset_delta(), self.reconnect + ) + except WebsocketError: + self.available = False + async_call_later(self.hass, LARGE_DELAY, self.reconnect) + + async def disconnect(self) -> None: + """Disconnect from the websocket.""" + with suppress(WebsocketError): + await self.client.disconnect() diff --git a/homeassistant/components/blue_current/config_flow.py b/homeassistant/components/blue_current/config_flow.py new file mode 100644 index 00000000000000..68a30fcdf7f394 --- /dev/null +++ b/homeassistant/components/blue_current/config_flow.py @@ -0,0 +1,83 @@ +"""Config flow for Blue Current integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from bluecurrent_api import Client +from bluecurrent_api.exceptions import ( + AlreadyConnected, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_TOKEN): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle the config flow for Blue Current.""" + + 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 = {} + if user_input is not None: + client = Client() + api_token = user_input[CONF_API_TOKEN] + + try: + customer_id = await client.validate_api_token(api_token) + email = await client.get_email() + except WebsocketError: + errors["base"] = "cannot_connect" + except RequestLimitReached: + errors["base"] = "limit_reached" + except AlreadyConnected: + errors["base"] = "already_connected" + except InvalidApiToken: + errors["base"] = "invalid_token" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + else: + if not self._reauth_entry: + await self.async_set_unique_id(customer_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=email, data=user_input) + + if self._reauth_entry.unique_id == customer_id: + 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_abort( + reason="wrong_account", + description_placeholders={"email": email}, + ) + 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 a reauthorization flow request.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py new file mode 100644 index 00000000000000..008e6efa872281 --- /dev/null +++ b/homeassistant/components/blue_current/const.py @@ -0,0 +1,10 @@ +"""Constants for the Blue Current integration.""" + +import logging + +DOMAIN = "blue_current" + +LOGGER = logging.getLogger(__package__) + +EVSE_ID = "evse_id" +MODEL_TYPE = "model_type" diff --git a/homeassistant/components/blue_current/entity.py b/homeassistant/components/blue_current/entity.py new file mode 100644 index 00000000000000..300f2191cdc91b --- /dev/null +++ b/homeassistant/components/blue_current/entity.py @@ -0,0 +1,63 @@ +"""Entity representing a Blue Current charge point.""" +from homeassistant.const import ATTR_NAME +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import Connector +from .const import DOMAIN, MODEL_TYPE + + +class BlueCurrentEntity(Entity): + """Define a base Blue Current entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, connector: Connector, signal: str) -> None: + """Initialize the entity.""" + self.connector: Connector = connector + self.signal: str = signal + self.has_value: bool = False + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def update() -> None: + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove(async_dispatcher_connect(self.hass, self.signal, update)) + + self.update_from_latest_data() + + @property + def available(self) -> bool: + """Return entity availability.""" + return self.connector.available and self.has_value + + @callback + def update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + raise NotImplementedError + + +class ChargepointEntity(BlueCurrentEntity): + """Define a base charge point entity.""" + + def __init__(self, connector: Connector, evse_id: str) -> None: + """Initialize the entity.""" + chargepoint_name = connector.charge_points[evse_id][ATTR_NAME] + + self.evse_id = evse_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, evse_id)}, + name=chargepoint_name if chargepoint_name != "" else evse_id, + manufacturer="Blue Current", + model=connector.charge_points[evse_id][MODEL_TYPE], + ) + + super().__init__(connector, f"{DOMAIN}_value_update_{self.evse_id}") diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json new file mode 100644 index 00000000000000..cadaac30d688da --- /dev/null +++ b/homeassistant/components/blue_current/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "blue_current", + "name": "Blue Current", + "codeowners": ["@Floris272", "@gleeuwen"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/blue_current", + "iot_class": "cloud_push", + "requirements": ["bluecurrent-api==1.0.6"] +} diff --git a/homeassistant/components/blue_current/sensor.py b/homeassistant/components/blue_current/sensor.py new file mode 100644 index 00000000000000..326caa70f54914 --- /dev/null +++ b/homeassistant/components/blue_current/sensor.py @@ -0,0 +1,296 @@ +"""Support for Blue Current sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CURRENCY_EURO, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Connector +from .const import DOMAIN +from .entity import BlueCurrentEntity, ChargepointEntity + +TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") + +SENSORS = ( + SensorEntityDescription( + key="actual_v1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_v2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_v3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="avg_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="avg_voltage", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="avg_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="avg_current", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="total_kw", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + translation_key="total_kw", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_kwh", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + translation_key="actual_kwh", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="start_datetime", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="start_datetime", + ), + SensorEntityDescription( + key="stop_datetime", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="stop_datetime", + ), + SensorEntityDescription( + key="offline_since", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="offline_since", + ), + SensorEntityDescription( + key="total_cost", + native_unit_of_measurement=CURRENCY_EURO, + device_class=SensorDeviceClass.MONETARY, + translation_key="total_cost", + ), + SensorEntityDescription( + key="vehicle_status", + icon="mdi:car", + device_class=SensorDeviceClass.ENUM, + options=["standby", "vehicle_detected", "ready", "no_power", "vehicle_error"], + translation_key="vehicle_status", + ), + SensorEntityDescription( + key="activity", + icon="mdi:ev-station", + device_class=SensorDeviceClass.ENUM, + options=["available", "charging", "unavailable", "error", "offline"], + translation_key="activity", + ), + SensorEntityDescription( + key="max_usage", + translation_key="max_usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="smartcharging_max_usage", + translation_key="smartcharging_max_usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="max_offline", + translation_key="max_offline", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="current_left", + translation_key="current_left", + icon="mdi:gauge", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +GRID_SENSORS = ( + SensorEntityDescription( + key="grid_actual_p1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_actual_p2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_actual_p3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_avg_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_avg_current", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_max_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_max_current", + state_class=SensorStateClass.MEASUREMENT, + ), +) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Blue Current sensors.""" + connector: Connector = hass.data[DOMAIN][entry.entry_id] + sensor_list: list[SensorEntity] = [] + for evse_id in connector.charge_points: + for sensor in SENSORS: + sensor_list.append(ChargePointSensor(connector, sensor, evse_id)) + + for grid_sensor in GRID_SENSORS: + sensor_list.append(GridSensor(connector, grid_sensor)) + + async_add_entities(sensor_list) + + +class ChargePointSensor(ChargepointEntity, SensorEntity): + """Define a charge point sensor.""" + + def __init__( + self, + connector: Connector, + sensor: SensorEntityDescription, + evse_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(connector, evse_id) + + self.key = sensor.key + self.entity_description = sensor + self._attr_unique_id = f"{sensor.key}_{evse_id}" + + @callback + def update_from_latest_data(self) -> None: + """Update the sensor from the latest data.""" + + new_value = self.connector.charge_points[self.evse_id].get(self.key) + + if new_value is not None: + if self.key in TIMESTAMP_KEYS and not ( + self._attr_native_value is None or self._attr_native_value < new_value + ): + return + self.has_value = True + self._attr_native_value = new_value + + elif self.key not in TIMESTAMP_KEYS: + self.has_value = False + + +class GridSensor(BlueCurrentEntity, SensorEntity): + """Define a grid sensor.""" + + def __init__( + self, + connector: Connector, + sensor: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(connector, f"{DOMAIN}_grid_update") + + self.key = sensor.key + self.entity_description = sensor + self._attr_unique_id = sensor.key + + @callback + def update_from_latest_data(self) -> None: + """Update the grid sensor from the latest data.""" + + new_value = self.connector.grid.get(self.key) + + if new_value is not None: + self.has_value = True + self._attr_native_value = new_value + + else: + self.has_value = False diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json new file mode 100644 index 00000000000000..293d0cd6ab783b --- /dev/null +++ b/homeassistant/components/blue_current/strings.json @@ -0,0 +1,119 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "description": "Enter your Blue Current api token", + "title": "Authentication" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "limit_reached": "Request limit reached", + "invalid_token": "Invalid token", + "no_cards_found": "No charge cards found", + "already_connected": "Already connected", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "Wrong account: Please authenticate with the api key for {email}." + } + }, + "entity": { + "sensor": { + "activity": { + "name": "Activity", + "state": { + "available": "Available", + "charging": "Charging", + "unavailable": "Unavailable", + "error": "Error", + "offline": "Offline" + } + }, + "vehicle_status": { + "name": "Vehicle status", + "state": { + "standby": "Standby", + "vehicle_detected": "Detected", + "ready": "Ready", + "no_power": "No power", + "vehicle_error": "Error" + } + }, + "actual_v1": { + "name": "Voltage phase 1" + }, + "actual_v2": { + "name": "Voltage phase 2" + }, + "actual_v3": { + "name": "Voltage phase 3" + }, + "avg_voltage": { + "name": "Average voltage" + }, + "actual_p1": { + "name": "Current phase 1" + }, + "actual_p2": { + "name": "Current phase 2" + }, + "actual_p3": { + "name": "Current phase 3" + }, + "avg_current": { + "name": "Average current" + }, + "total_kw": { + "name": "Total power" + }, + "actual_kwh": { + "name": "Energy usage" + }, + "start_datetime": { + "name": "Started on" + }, + "stop_datetime": { + "name": "Stopped on" + }, + "offline_since": { + "name": "Offline since" + }, + "total_cost": { + "name": "Total cost" + }, + "max_usage": { + "name": "Max usage" + }, + "smartcharging_max_usage": { + "name": "Smart charging max usage" + }, + "max_offline": { + "name": "Offline max usage" + }, + "current_left": { + "name": "Remaining current" + }, + "grid_actual_p1": { + "name": "Grid current phase 1" + }, + "grid_actual_p2": { + "name": "Grid current phase 2" + }, + "grid_actual_p3": { + "name": "Grid current phase 3" + }, + "grid_avg_current": { + "name": "Average grid current" + }, + "grid_max_current": { + "name": "Max grid current" + } + } + } +} diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 6f48080a451816..33fb87cc578e22 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 @@ -90,17 +90,17 @@ def __init__( @property def name(self) -> str: """Return blueprint name.""" - return self.data[CONF_BLUEPRINT][CONF_NAME] + return self.data[CONF_BLUEPRINT][CONF_NAME] # type: ignore[no-any-return] @property - def inputs(self) -> dict: + def inputs(self) -> dict[str, Any]: """Return blueprint inputs.""" - return self.data[CONF_BLUEPRINT][CONF_INPUT] + return self.data[CONF_BLUEPRINT][CONF_INPUT] # type: ignore[no-any-return] @property - def metadata(self) -> dict: + def metadata(self) -> dict[str, Any]: """Return blueprint metadata.""" - return self.data[CONF_BLUEPRINT] + return self.data[CONF_BLUEPRINT] # type: ignore[no-any-return] def update_metadata(self, *, source_url: str | None = None) -> None: """Update metadata.""" @@ -140,12 +140,12 @@ def __init__( self.config_with_inputs = config_with_inputs @property - def inputs(self): + def inputs(self) -> dict[str, Any]: """Return the inputs.""" - return self.config_with_inputs[CONF_USE_BLUEPRINT][CONF_INPUT] + return self.config_with_inputs[CONF_USE_BLUEPRINT][CONF_INPUT] # type: ignore[no-any-return] @property - def inputs_with_default(self): + def inputs_with_default(self) -> dict[str, Any]: """Return the inputs and fallback to defaults.""" no_input = set(self.blueprint.inputs) - set(self.inputs) @@ -189,12 +189,14 @@ def __init__( 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() @@ -210,10 +212,10 @@ async def async_reset_cache(self) -> None: async with self._load_lock: self._blueprints = {} - def _load_blueprint(self, blueprint_path) -> Blueprint: + def _load_blueprint(self, blueprint_path: str) -> Blueprint: """Load a blueprint.""" try: - blueprint_data = yaml.load_yaml(self.blueprint_folder / blueprint_path) + blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path) except FileNotFoundError as err: raise FailedToLoad( self.domain, @@ -223,7 +225,6 @@ def _load_blueprint(self, blueprint_path) -> Blueprint: except HomeAssistantError as err: raise FailedToLoad(self.domain, blueprint_path, err) from err - assert isinstance(blueprint_data, dict) return Blueprint( blueprint_data, expected_domain=self.domain, path=blueprint_path ) @@ -261,7 +262,7 @@ async def async_get_blueprints( async def async_get_blueprint(self, blueprint_path: str) -> Blueprint: """Get a blueprint.""" - def load_from_cache(): + def load_from_cache() -> Blueprint: """Load blueprint from cache.""" if (blueprint := self._blueprints[blueprint_path]) is None: raise FailedToLoad( @@ -283,7 +284,7 @@ def load_from_cache(): 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 +316,41 @@ async def async_remove_blueprint(self, blueprint_path: str) -> None: 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: bool = 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: @@ -348,7 +359,7 @@ async def async_populate(self) -> None: integration = await loader.async_get_integration(self.hass, self.domain) - def populate(): + def populate() -> None: if self.blueprint_folder.exists(): return diff --git a/homeassistant/components/blueprint/schemas.py b/homeassistant/components/blueprint/schemas.py index c8271cc700d67e..fd3aa967336e30 100644 --- a/homeassistant/components/blueprint/schemas.py +++ b/homeassistant/components/blueprint/schemas.py @@ -25,7 +25,7 @@ ) -def version_validator(value): +def version_validator(value: Any) -> str: """Validate a Home Assistant version.""" if not isinstance(value, str): raise vol.Invalid("Version needs to be a string") @@ -36,7 +36,7 @@ def version_validator(value): raise vol.Invalid("Version needs to be formatted as {major}.{minor}.{patch}") try: - parts = [int(p) for p in parts] + [int(p) for p in parts] except ValueError: raise vol.Invalid( "Major, minor and patch version needs to be an integer" diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 1732320c1e905e..1989f0f563c0d8 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -14,11 +14,11 @@ from . import importer, models from .const import DOMAIN -from .errors import FileAlreadyExists +from .errors import FailedToLoad, FileAlreadyExists @callback -def async_setup(hass: HomeAssistant): +def async_setup(hass: HomeAssistant) -> None: """Set up the websocket API.""" websocket_api.async_register_command(hass, ws_list_blueprints) websocket_api.async_register_command(hass, ws_import_blueprint) @@ -76,11 +76,28 @@ async def ws_import_blueprint( imported_blueprint = await importer.fetch_blueprint_from_url(hass, msg["url"]) if imported_blueprint is None: - connection.send_error( + connection.send_error( # type: ignore[unreachable] msg["id"], websocket_api.ERR_NOT_SUPPORTED, "This url is not supported" ) 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/__init__.py b/homeassistant/components/bluetooth/__init__.py index c59249e8bd55b7..2dd4f06ecdff2a 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -21,6 +21,17 @@ adapter_unique_name, get_adapters, ) +from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME +from habluetooth import ( + BaseHaRemoteScanner, + BaseHaScanner, + BluetoothScannerDevice, + BluetoothScanningMode, + HaBluetoothConnector, + HaScanner, + ScannerStartError, + set_manager, +) from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb @@ -59,7 +70,6 @@ async_set_fallback_availability_interval, async_track_unavailable, ) -from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice from .const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_ADAPTER, @@ -71,15 +81,9 @@ LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, ) -from .manager import BluetoothManager +from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - HaBluetoothConnector, -) -from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError +from .models import BluetoothCallback, BluetoothChange from .storage import BluetoothStorage if TYPE_CHECKING: @@ -102,8 +106,9 @@ "async_scanner_by_source", "async_scanner_count", "async_scanner_devices_by_address", + "async_get_advertisement_callback", "BaseHaScanner", - "BaseHaRemoteScanner", + "HomeAssistantRemoteScanner", "BluetoothCallbackMatcher", "BluetoothChange", "BluetoothServiceInfo", @@ -112,6 +117,7 @@ "BluetoothCallback", "BluetoothScannerDevice", "HaBluetoothConnector", + "BaseHaRemoteScanner", "SOURCE_LOCAL", "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", "MONOTONIC_TIME", @@ -139,11 +145,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await bluetooth_storage.async_setup() slot_manager = BleakSlotManager() await slot_manager.async_setup() - manager = BluetoothManager( + manager = HomeAssistantBluetoothManager( hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager ) + set_manager(manager) await manager.async_setup() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda event: manager.async_stop() + ) hass.data[DATA_MANAGER] = models.MANAGER = manager adapters = await manager.async_get_bluetooth_adapters() @@ -279,9 +288,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: passive = entry.options.get(CONF_PASSIVE) mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE - new_info_callback = async_get_advertisement_callback(hass) - manager: BluetoothManager = hass.data[DATA_MANAGER] - scanner = HaScanner(hass, mode, adapter, address, new_info_callback) + manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] + scanner = HaScanner(mode, adapter, address) try: scanner.async_setup() except RuntimeError as err: @@ -295,7 +303,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: adapters = await manager.async_get_bluetooth_adapters() details = adapters[adapter] slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS - entry.async_on_unload(async_register_scanner(hass, scanner, True, slots)) + entry.async_on_unload(async_register_scanner(hass, scanner, connection_slots=slots)) await async_update_device(hass, entry, adapter, details) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner entry.async_on_unload(entry.add_update_listener(async_update_listener)) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index cdf51d34978423..174e5c66ce8512 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -9,10 +9,10 @@ from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index a3f5e20a9e97e3..3a13dda28a8a6d 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -9,10 +9,10 @@ from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_processor import PassiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py deleted file mode 100644 index f17bcf938f598a..00000000000000 --- a/homeassistant/components/bluetooth/advertisement_tracker.py +++ /dev/null @@ -1,82 +0,0 @@ -"""The bluetooth integration advertisement tracker.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.core import callback - -from .models import BluetoothServiceInfoBleak - -ADVERTISING_TIMES_NEEDED = 16 - -# Each scanner may buffer incoming packets so -# we need to give a bit of leeway before we -# mark a device unavailable -TRACKER_BUFFERING_WOBBLE_SECONDS = 5 - - -class AdvertisementTracker: - """Tracker to determine the interval that a device is advertising.""" - - __slots__ = ("intervals", "fallback_intervals", "sources", "_timings") - - def __init__(self) -> None: - """Initialize the tracker.""" - self.intervals: dict[str, float] = {} - self.fallback_intervals: dict[str, float] = {} - self.sources: dict[str, str] = {} - self._timings: dict[str, list[float]] = {} - - @callback - def async_diagnostics(self) -> dict[str, dict[str, Any]]: - """Return diagnostics.""" - return { - "intervals": self.intervals, - "fallback_intervals": self.fallback_intervals, - "sources": self.sources, - "timings": self._timings, - } - - @callback - def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None: - """Collect timings for the tracker. - - For performance reasons, it is the responsibility of the - caller to check if the device already has an interval set or - the source has changed before calling this function. - """ - address = service_info.address - self.sources[address] = service_info.source - timings = self._timings.setdefault(address, []) - timings.append(service_info.time) - if len(timings) != ADVERTISING_TIMES_NEEDED: - return - - max_time_between_advertisements = timings[1] - timings[0] - for i in range(2, len(timings)): - time_between_advertisements = timings[i] - timings[i - 1] - if time_between_advertisements > max_time_between_advertisements: - max_time_between_advertisements = time_between_advertisements - - # We now know the maximum time between advertisements - self.intervals[address] = max_time_between_advertisements - del self._timings[address] - - @callback - def async_remove_address(self, address: str) -> None: - """Remove the tracker.""" - self.intervals.pop(address, None) - self.sources.pop(address, None) - self._timings.pop(address, None) - - @callback - def async_remove_fallback_interval(self, address: str) -> None: - """Remove fallback interval.""" - self.fallback_intervals.pop(address, None) - - @callback - def async_remove_source(self, source: str) -> None: - """Remove the tracker.""" - for address, tracked_source in list(self.sources.items()): - if tracked_source == source: - self.async_remove_address(address) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9d24428e3d2732..29054a54e724ca 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -9,29 +9,28 @@ from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast +from habluetooth import ( + BaseHaScanner, + BluetoothScannerDevice, + BluetoothScanningMode, + HaBleakScannerWrapper, +) from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER -from .manager import BluetoothManager +from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - ProcessAdvertisementCallback, -) -from .wrappers import HaBleakScannerWrapper +from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback if TYPE_CHECKING: from bleak.backends.device import BLEDevice -def _get_manager(hass: HomeAssistant) -> BluetoothManager: +def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager: """Get the bluetooth manager.""" - return cast(BluetoothManager, hass.data[DATA_MANAGER]) + return cast(HomeAssistantBluetoothManager, hass.data[DATA_MANAGER]) @hass_callback @@ -182,13 +181,10 @@ def async_rediscover_address(hass: HomeAssistant, address: str) -> None: def async_register_scanner( hass: HomeAssistant, scanner: BaseHaScanner, - connectable: bool, connection_slots: int | None = None, ) -> CALLBACK_TYPE: """Register a BleakScanner.""" - return _get_manager(hass).async_register_scanner( - scanner, connectable, connection_slots - ) + return _get_manager(hass).async_register_scanner(scanner, connection_slots) @hass_callback diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py deleted file mode 100644 index 8eacd3e291a31f..00000000000000 --- a/homeassistant/components/bluetooth/base_scanner.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Base classes for HA Bluetooth scanners for bluetooth.""" -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Callable, Generator -from contextlib import contextmanager -from dataclasses import dataclass -import datetime -from datetime import timedelta -import logging -from typing import Any, Final - -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData -from bleak_retry_connector import NO_RSSI_VALUE -from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name -from home_assistant_bluetooth import BluetoothServiceInfoBleak - -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ( - CALLBACK_TYPE, - Event, - HomeAssistant, - callback as hass_callback, -) -from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util -from homeassistant.util.dt import monotonic_time_coarse - -from . import models -from .const import ( - CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, -) -from .models import HaBluetoothConnector - -MONOTONIC_TIME: Final = monotonic_time_coarse -_LOGGER = logging.getLogger(__name__) - - -@dataclass(slots=True) -class BluetoothScannerDevice: - """Data for a bluetooth device from a given scanner.""" - - scanner: BaseHaScanner - ble_device: BLEDevice - advertisement: AdvertisementData - - -class BaseHaScanner(ABC): - """Base class for Ha Scanners.""" - - __slots__ = ( - "hass", - "adapter", - "connectable", - "source", - "connector", - "_connecting", - "name", - "scanning", - "_last_detection", - "_start_time", - "_cancel_watchdog", - ) - - def __init__( - self, - hass: HomeAssistant, - source: str, - adapter: str, - connector: HaBluetoothConnector | None = None, - ) -> None: - """Initialize the scanner.""" - self.hass = hass - self.connectable = False - self.source = source - self.connector = connector - self._connecting = 0 - self.adapter = adapter - self.name = adapter_human_name(adapter, source) if adapter != source else source - self.scanning = True - self._last_detection = 0.0 - self._start_time = 0.0 - self._cancel_watchdog: CALLBACK_TYPE | None = None - - @hass_callback - def _async_stop_scanner_watchdog(self) -> None: - """Stop the scanner watchdog.""" - if self._cancel_watchdog: - self._cancel_watchdog() - self._cancel_watchdog = None - - @hass_callback - def _async_setup_scanner_watchdog(self) -> None: - """If something has restarted or updated, we need to restart the scanner.""" - self._start_time = self._last_detection = MONOTONIC_TIME() - if not self._cancel_watchdog: - self._cancel_watchdog = async_track_time_interval( - self.hass, - self._async_scanner_watchdog, - SCANNER_WATCHDOG_INTERVAL, - name=f"{self.name} Bluetooth scanner watchdog", - ) - - @hass_callback - def _async_watchdog_triggered(self) -> bool: - """Check if the watchdog has been triggered.""" - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - _LOGGER.debug( - "%s: Scanner watchdog time_since_last_detection: %s", - self.name, - time_since_last_detection, - ) - return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT - - @hass_callback - def _async_scanner_watchdog(self, now: datetime.datetime) -> None: - """Check if the scanner is running. - - Override this method if you need to do something else when the watchdog - is triggered. - """ - if self._async_watchdog_triggered(): - _LOGGER.info( - ( - "%s: Bluetooth scanner has gone quiet for %ss, check logs on the" - " scanner device for more information" - ), - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - self.scanning = False - return - self.scanning = not self._connecting - - @contextmanager - def connecting(self) -> Generator[None, None, None]: - """Context manager to track connecting state.""" - self._connecting += 1 - self.scanning = not self._connecting - try: - yield - finally: - self._connecting -= 1 - self.scanning = not self._connecting - - @property - @abstractmethod - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - - @property - @abstractmethod - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and their advertisement data.""" - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - device_adv_datas = self.discovered_devices_and_advertisement_data.values() - return { - "name": self.name, - "start_time": self._start_time, - "source": self.source, - "scanning": self.scanning, - "type": self.__class__.__name__, - "last_detection": self._last_detection, - "monotonic_time": MONOTONIC_TIME(), - "discovered_devices_and_advertisement_data": [ - { - "name": device.name, - "address": device.address, - "rssi": advertisement_data.rssi, - "advertisement_data": advertisement_data, - "details": device.details, - } - for device, advertisement_data in device_adv_datas - ], - } - - -class BaseHaRemoteScanner(BaseHaScanner): - """Base class for a Home Assistant remote BLE scanner.""" - - __slots__ = ( - "_new_info_callback", - "_discovered_device_advertisement_datas", - "_discovered_device_timestamps", - "_details", - "_expire_seconds", - "_storage", - ) - - def __init__( - self, - hass: HomeAssistant, - scanner_id: str, - name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - connector: HaBluetoothConnector | None, - connectable: bool, - ) -> None: - """Initialize the scanner.""" - super().__init__(hass, scanner_id, name, connector) - self._new_info_callback = new_info_callback - self._discovered_device_advertisement_datas: dict[ - str, tuple[BLEDevice, AdvertisementData] - ] = {} - self._discovered_device_timestamps: dict[str, float] = {} - self.connectable = connectable - self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} - # Scanners only care about connectable devices. The manager - # will handle taking care of availability for non-connectable devices - self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - assert models.MANAGER is not None - self._storage = models.MANAGER.storage - - @hass_callback - def async_setup(self) -> CALLBACK_TYPE: - """Set up the scanner.""" - if history := self._storage.async_get_advertisement_history(self.source): - self._discovered_device_advertisement_datas = ( - history.discovered_device_advertisement_datas - ) - self._discovered_device_timestamps = history.discovered_device_timestamps - # Expire anything that is too old - self._async_expire_devices(dt_util.utcnow()) - - cancel_track = async_track_time_interval( - self.hass, - self._async_expire_devices, - timedelta(seconds=30), - name=f"{self.name} Bluetooth scanner device expire", - ) - cancel_stop = self.hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, self._async_save_history - ) - self._async_setup_scanner_watchdog() - - @hass_callback - def _cancel() -> None: - self._async_save_history() - self._async_stop_scanner_watchdog() - cancel_track() - cancel_stop() - - return _cancel - - @hass_callback - def _async_save_history(self, event: Event | None = None) -> None: - """Save the history.""" - self._storage.async_set_advertisement_history( - self.source, - DiscoveredDeviceAdvertisementData( - self.connectable, - self._expire_seconds, - self._discovered_device_advertisement_datas, - self._discovered_device_timestamps, - ), - ) - - @hass_callback - def _async_expire_devices(self, _datetime: datetime.datetime) -> None: - """Expire old devices.""" - now = MONOTONIC_TIME() - expired = [ - address - for address, timestamp in self._discovered_device_timestamps.items() - if now - timestamp > self._expire_seconds - ] - for address in expired: - del self._discovered_device_advertisement_datas[address] - del self._discovered_device_timestamps[address] - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - device_adv_datas = self._discovered_device_advertisement_datas.values() - return [ - device_advertisement_data[0] - for device_advertisement_data in device_adv_datas - ] - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self._discovered_device_advertisement_datas - - @hass_callback - def _async_on_advertisement( - self, - address: str, - rssi: int, - local_name: str | None, - service_uuids: list[str], - service_data: dict[str, bytes], - manufacturer_data: dict[int, bytes], - tx_power: int | None, - details: dict[Any, Any], - advertisement_monotonic_time: float, - ) -> None: - """Call the registered callback.""" - self.scanning = not self._connecting - self._last_detection = advertisement_monotonic_time - try: - prev_discovery = self._discovered_device_advertisement_datas[address] - except KeyError: - # We expect this is the rare case and since py3.11+ has - # near zero cost try on success, and we can avoid .get() - # which is slower than [] we use the try/except pattern. - device = BLEDevice( - address=address, - name=local_name, - details=self._details | details, - rssi=rssi, # deprecated, will be removed in newer bleak - ) - else: - # Merge the new data with the old data - # to function the same as BlueZ which - # merges the dicts on PropertiesChanged - prev_device = prev_discovery[0] - prev_advertisement = prev_discovery[1] - prev_service_uuids = prev_advertisement.service_uuids - prev_service_data = prev_advertisement.service_data - prev_manufacturer_data = prev_advertisement.manufacturer_data - prev_name = prev_device.name - - if prev_name and (not local_name or len(prev_name) > len(local_name)): - local_name = prev_name - - if service_uuids and service_uuids != prev_service_uuids: - service_uuids = list(set(service_uuids + prev_service_uuids)) - elif not service_uuids: - service_uuids = prev_service_uuids - - if service_data and service_data != prev_service_data: - service_data = prev_service_data | service_data - elif not service_data: - service_data = prev_service_data - - if manufacturer_data and manufacturer_data != prev_manufacturer_data: - manufacturer_data = prev_manufacturer_data | manufacturer_data - elif not manufacturer_data: - manufacturer_data = prev_manufacturer_data - # - # Bleak updates the BLEDevice via create_or_update_device. - # We need to do the same to ensure integrations that already - # have the BLEDevice object get the updated details when they - # change. - # - # https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203 - # - device = prev_device - device.name = local_name - device.details = self._details | details - # pylint: disable-next=protected-access - device._rssi = rssi # deprecated, will be removed in newer bleak - - advertisement_data = AdvertisementData( - local_name=None if local_name == "" else local_name, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, - rssi=rssi, - platform_data=(), - ) - self._discovered_device_advertisement_datas[address] = ( - device, - advertisement_data, - ) - self._discovered_device_timestamps[address] = advertisement_monotonic_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=local_name or address, - address=address, - rssi=rssi, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=self.connectable, - time=advertisement_monotonic_time, - ) - ) - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - now = MONOTONIC_TIME() - return await super().async_diagnostics() | { - "storage": self._storage.async_get_advertisement_history_as_dict( - self.source - ), - "connectable": self.connectable, - "discovered_device_timestamps": self._discovered_device_timestamps, - "time_since_last_device_detection": { - address: now - timestamp - for address, timestamp in self._discovered_device_timestamps.items() - }, - } diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 150239eec021ea..fa8efabcb1d762 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -1,9 +1,15 @@ """Constants for the Bluetooth integration.""" from __future__ import annotations -from datetime import timedelta from typing import Final +from habluetooth import ( # noqa: F401 + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, +) + DOMAIN = "bluetooth" CONF_ADAPTER = "adapter" @@ -19,42 +25,6 @@ START_TIMEOUT = 15 -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker cannot determine the interval. -# -# We have to set this quite high as we don't know -# when devices fall out of the ESPHome device (and other non-local scanners)'s -# stack like we do with BlueZ so its safer to assume its available -# since if it does go out of range and it is in range -# of another device the timeout is much shorter and it will -# switch over to using that adapter anyways. -# -FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15 - -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker can determine the interval for -# connectable devices. -# -# BlueZ uses 180 seconds by default but we give it a bit more time -# to account for the esp32's bluetooth stack being a bit slower -# than BlueZ's. -CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195 - - -# We must recover before we hit the 180s mark -# where the device is removed from the stack -# or the devices will go unavailable. Since -# we only check every 30s, we need this number -# to be -# 180s Time when device is removed from stack -# - 30s check interval -# - 30s scanner restart time * 2 -# -SCANNER_WATCHDOG_TIMEOUT: Final = 90 -# How often to check if the scanner has reached -# the SCANNER_WATCHDOG_TIMEOUT without seeing anything -SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30) - # When the linux kernel is configured with # CONFIG_FW_LOADER_USER_HELPER_FALLBACK it diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 34edccaf4ab3ff..381beb02520ea4 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -1,21 +1,14 @@ """The bluetooth integration.""" from __future__ import annotations -import asyncio from collections.abc import Callable, Iterable -from datetime import datetime, timedelta +from functools import partial import itertools import logging -from typing import TYPE_CHECKING, Any, Final -from bleak.backends.scanner import AdvertisementDataCallback -from bleak_retry_connector import NO_RSSI_VALUE, RSSI_SWITCH_THRESHOLD, BleakSlotManager -from bluetooth_adapters import ( - ADAPTER_ADDRESS, - ADAPTER_PASSIVE_SCAN, - AdapterDetails, - BluetoothAdapters, -) +from bleak_retry_connector import BleakSlotManager +from bluetooth_adapters import BluetoothAdapters +from habluetooth import BaseHaRemoteScanner, BaseHaScanner, BluetoothManager from homeassistant import config_entries from homeassistant.const import EVENT_LOGGING_CHANGED @@ -26,18 +19,7 @@ callback as hass_callback, ) from homeassistant.helpers import discovery_flow -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.dt import monotonic_time_coarse -from .advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, - AdvertisementTracker, -) -from .base_scanner import BaseHaScanner, BluetoothScannerDevice -from .const import ( - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, - UNAVAILABLE_TRACK_SECONDS, -) from .match import ( ADDRESS, CALLBACK, @@ -50,80 +32,20 @@ ) from .models import BluetoothCallback, BluetoothChange, BluetoothServiceInfoBleak from .storage import BluetoothStorage -from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher from .util import async_load_history_from_system -if TYPE_CHECKING: - from bleak.backends.device import BLEDevice - from bleak.backends.scanner import AdvertisementData - - -FILTER_UUIDS: Final = "UUIDs" - -APPLE_MFR_ID: Final = 76 -APPLE_IBEACON_START_BYTE: Final = 0x02 # iBeacon (tilt_ble) -APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller -APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker -APPLE_HOMEKIT_NOTIFY_START_BYTE: Final = 0x11 # homekit_controller -APPLE_START_BYTES_WANTED: Final = { - APPLE_IBEACON_START_BYTE, - APPLE_HOMEKIT_START_BYTE, - APPLE_HOMEKIT_NOTIFY_START_BYTE, - APPLE_DEVICE_ID_START_BYTE, -} - -MONOTONIC_TIME: Final = monotonic_time_coarse - _LOGGER = logging.getLogger(__name__) -def _dispatch_bleak_callback( - callback: AdvertisementDataCallback | None, - filters: dict[str, set[str]], - device: BLEDevice, - advertisement_data: AdvertisementData, -) -> None: - """Dispatch the callback.""" - if not callback: - # Callback destroyed right before being called, ignore - return - - if (uuids := filters.get(FILTER_UUIDS)) and not uuids.intersection( - advertisement_data.service_uuids - ): - return - - try: - callback(device, advertisement_data) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in callback: %s", callback) - - -class BluetoothManager: - """Manage Bluetooth.""" +class HomeAssistantBluetoothManager(BluetoothManager): + """Manage Bluetooth for Home Assistant.""" __slots__ = ( "hass", + "storage", "_integration_matcher", - "_cancel_unavailable_tracking", - "_cancel_logging_listener", - "_advertisement_tracker", - "_fallback_intervals", - "_intervals", - "_unavailable_callbacks", - "_connectable_unavailable_callbacks", "_callback_index", - "_bleak_callbacks", - "_all_history", - "_connectable_history", - "_non_connectable_scanners", - "_connectable_scanners", - "_adapters", - "_sources", - "_bluetooth_adapters", - "storage", - "slot_manager", - "_debug", + "_cancel_logging_listener", ) def __init__( @@ -136,456 +58,51 @@ def __init__( ) -> None: """Init bluetooth manager.""" self.hass = hass + self.storage = storage self._integration_matcher = integration_matcher - self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None - self._cancel_logging_listener: CALLBACK_TYPE | None = None - - self._advertisement_tracker = AdvertisementTracker() - self._fallback_intervals = self._advertisement_tracker.fallback_intervals - self._intervals = self._advertisement_tracker.intervals - - self._unavailable_callbacks: dict[ - str, list[Callable[[BluetoothServiceInfoBleak], None]] - ] = {} - self._connectable_unavailable_callbacks: dict[ - str, list[Callable[[BluetoothServiceInfoBleak], None]] - ] = {} - self._callback_index = BluetoothCallbackMatcherIndex() - self._bleak_callbacks: list[ - tuple[AdvertisementDataCallback, dict[str, set[str]]] - ] = [] - self._all_history: dict[str, BluetoothServiceInfoBleak] = {} - self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {} - self._non_connectable_scanners: list[BaseHaScanner] = [] - self._connectable_scanners: list[BaseHaScanner] = [] - self._adapters: dict[str, AdapterDetails] = {} - self._sources: dict[str, BaseHaScanner] = {} - self._bluetooth_adapters = bluetooth_adapters - self.storage = storage - self.slot_manager = slot_manager - self._debug = _LOGGER.isEnabledFor(logging.DEBUG) - - @property - def supports_passive_scan(self) -> bool: - """Return if passive scan is supported.""" - return any(adapter[ADAPTER_PASSIVE_SCAN] for adapter in self._adapters.values()) - - def async_scanner_count(self, connectable: bool = True) -> int: - """Return the number of scanners.""" - if connectable: - return len(self._connectable_scanners) - return len(self._connectable_scanners) + len(self._non_connectable_scanners) - - async def async_diagnostics(self) -> dict[str, Any]: - """Diagnostics for the manager.""" - scanner_diagnostics = await asyncio.gather( - *[ - scanner.async_diagnostics() - for scanner in itertools.chain( - self._non_connectable_scanners, self._connectable_scanners - ) - ] - ) - return { - "adapters": self._adapters, - "slot_manager": self.slot_manager.diagnostics(), - "scanners": scanner_diagnostics, - "connectable_history": [ - service_info.as_dict() - for service_info in self._connectable_history.values() - ], - "all_history": [ - service_info.as_dict() for service_info in self._all_history.values() - ], - "advertisement_tracker": self._advertisement_tracker.async_diagnostics(), - } - - def _find_adapter_by_address(self, address: str) -> str | None: - for adapter, details in self._adapters.items(): - if details[ADAPTER_ADDRESS] == address: - return adapter - return None - - @hass_callback - def async_scanner_by_source(self, source: str) -> BaseHaScanner | None: - """Return the scanner for a source.""" - return self._sources.get(source) - - async def async_get_bluetooth_adapters( - self, cached: bool = True - ) -> dict[str, AdapterDetails]: - """Get bluetooth adapters.""" - if not self._adapters or not cached: - if not cached: - await self._bluetooth_adapters.refresh() - self._adapters = self._bluetooth_adapters.adapters - return self._adapters - - async def async_get_adapter_from_address(self, address: str) -> str | None: - """Get adapter from address.""" - if adapter := self._find_adapter_by_address(address): - return adapter - await self._bluetooth_adapters.refresh() - self._adapters = self._bluetooth_adapters.adapters - return self._find_adapter_by_address(address) + self._cancel_logging_listener: CALLBACK_TYPE | None = None + super().__init__(bluetooth_adapters, slot_manager) + self._async_logging_changed() @hass_callback - def _async_logging_changed(self, event: Event) -> None: + def _async_logging_changed(self, event: Event | None = None) -> None: """Handle logging change.""" self._debug = _LOGGER.isEnabledFor(logging.DEBUG) - async def async_setup(self) -> None: - """Set up the bluetooth manager.""" - await self._bluetooth_adapters.refresh() - install_multiple_bleak_catcher() - self._all_history, self._connectable_history = async_load_history_from_system( - self._bluetooth_adapters, self.storage - ) - self._cancel_logging_listener = self.hass.bus.async_listen( - EVENT_LOGGING_CHANGED, self._async_logging_changed - ) - self.async_setup_unavailable_tracking() - seen: set[str] = set() - for address, service_info in itertools.chain( - self._connectable_history.items(), self._all_history.items() - ): - if address in seen: - continue - seen.add(address) - self._async_trigger_matching_discovery(service_info) - - @hass_callback - def async_stop(self, event: Event) -> None: - """Stop the Bluetooth integration at shutdown.""" - _LOGGER.debug("Stopping bluetooth manager") - if self._cancel_unavailable_tracking: - self._cancel_unavailable_tracking() - self._cancel_unavailable_tracking = None - if self._cancel_logging_listener: - self._cancel_logging_listener() - self._cancel_logging_listener = None - uninstall_multiple_bleak_catcher() - - @hass_callback - def async_scanner_devices_by_address( - self, address: str, connectable: bool - ) -> list[BluetoothScannerDevice]: - """Get BluetoothScannerDevice by address.""" - if not connectable: - scanners: Iterable[BaseHaScanner] = itertools.chain( - self._connectable_scanners, self._non_connectable_scanners - ) - else: - scanners = self._connectable_scanners - return [ - BluetoothScannerDevice(scanner, *device_adv) - for scanner in scanners - if ( - device_adv := scanner.discovered_devices_and_advertisement_data.get( - address - ) - ) - ] - - @hass_callback - def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: - """Return all of discovered addresses. - - Include addresses from all the scanners including duplicates. - """ - yield from itertools.chain.from_iterable( - scanner.discovered_devices_and_advertisement_data - for scanner in self._connectable_scanners - ) - if not connectable: - yield from itertools.chain.from_iterable( - scanner.discovered_devices_and_advertisement_data - for scanner in self._non_connectable_scanners - ) - - @hass_callback - def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: - """Return all of combined best path to discovered from all the scanners.""" - histories = self._connectable_history if connectable else self._all_history - return [history.device for history in histories.values()] - - @hass_callback - def async_setup_unavailable_tracking(self) -> None: - """Set up the unavailable tracking.""" - self._cancel_unavailable_tracking = async_track_time_interval( - self.hass, - self._async_check_unavailable, - timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), - name="Bluetooth manager unavailable tracking", - ) - - @hass_callback - def _async_check_unavailable(self, now: datetime) -> None: - """Watch for unavailable devices and cleanup state history.""" - monotonic_now = MONOTONIC_TIME() - connectable_history = self._connectable_history - all_history = self._all_history - tracker = self._advertisement_tracker - intervals = tracker.intervals - - for connectable in (True, False): - if connectable: - unavailable_callbacks = self._connectable_unavailable_callbacks - else: - unavailable_callbacks = self._unavailable_callbacks - history = connectable_history if connectable else all_history - disappeared = set(history).difference( - self._async_all_discovered_addresses(connectable) - ) - for address in disappeared: - if not connectable: - # - # For non-connectable devices we also check the device has exceeded - # the advertising interval before we mark it as unavailable - # since it may have gone to sleep and since we do not need an active - # connection to it we can only determine its availability - # by the lack of advertisements - if advertising_interval := ( - intervals.get(address) or self._fallback_intervals.get(address) - ): - advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS - else: - advertising_interval = ( - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - ) - time_since_seen = monotonic_now - all_history[address].time - if time_since_seen <= advertising_interval: - continue - - # The second loop (connectable=False) is responsible for removing - # the device from all the interval tracking since it is no longer - # available for both connectable and non-connectable - tracker.async_remove_fallback_interval(address) - tracker.async_remove_address(address) - self._integration_matcher.async_clear_address(address) - self._async_dismiss_discoveries(address) - - service_info = history.pop(address) - - if not (callbacks := unavailable_callbacks.get(address)): - continue - - for callback in callbacks: - try: - callback(service_info) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in unavailable callback") - - def _async_dismiss_discoveries(self, address: str) -> None: - """Dismiss all discoveries for the given address.""" - for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - BluetoothServiceInfoBleak, - lambda service_info: bool(service_info.address == address), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) - - def _prefer_previous_adv_from_different_source( - self, - old: BluetoothServiceInfoBleak, - new: BluetoothServiceInfoBleak, - ) -> bool: - """Prefer previous advertisement from a different source if it is better.""" - if new.time - old.time > ( - stale_seconds := self._intervals.get( - new.address, - self._fallback_intervals.get( - new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - ), + def _async_trigger_matching_discovery( + self, service_info: BluetoothServiceInfoBleak + ) -> None: + """Trigger discovery for matching domains.""" + for domain in self._integration_matcher.match_domains(service_info): + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, ) - ): - # If the old advertisement is stale, any new advertisement is preferred - if self._debug: - _LOGGER.debug( - ( - "%s (%s): Switching from %s to %s (time elapsed:%s > stale" - " seconds:%s)" - ), - new.name, - new.address, - self._async_describe_source(old), - self._async_describe_source(new), - new.time - old.time, - stale_seconds, - ) - return False - if (new.rssi or NO_RSSI_VALUE) - RSSI_SWITCH_THRESHOLD > ( - old.rssi or NO_RSSI_VALUE - ): - # If new advertisement is RSSI_SWITCH_THRESHOLD more, - # the new one is preferred. - if self._debug: - _LOGGER.debug( - ( - "%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >" - " old rssi:%s)" - ), - new.name, - new.address, - self._async_describe_source(old), - self._async_describe_source(new), - new.rssi, - RSSI_SWITCH_THRESHOLD, - old.rssi, - ) - return False - return True @hass_callback - def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: - """Handle a new advertisement from any scanner. - - Callbacks from all the scanners arrive here. - """ - - # Pre-filter noisy apple devices as they can account for 20-35% of the - # traffic on a typical network. - if ( - (manufacturer_data := service_info.manufacturer_data) - and APPLE_MFR_ID in manufacturer_data - and manufacturer_data[APPLE_MFR_ID][0] not in APPLE_START_BYTES_WANTED - and len(manufacturer_data) == 1 - and not service_info.service_data - ): - return - - address = service_info.device.address - all_history = self._all_history - connectable = service_info.connectable - connectable_history = self._connectable_history - old_connectable_service_info = connectable and connectable_history.get(address) - source = service_info.source - # This logic is complex due to the many combinations of scanners - # that are supported. - # - # We need to handle multiple connectable and non-connectable scanners - # and we need to handle the case where a device is connectable on one scanner - # but not on another. - # - # The device may also be connectable only by a scanner that has worse - # signal strength than a non-connectable scanner. - # - # all_history - the history of all advertisements from all scanners with the - # best advertisement from each scanner - # connectable_history - the history of all connectable advertisements from all - # scanners with the best advertisement from each - # connectable scanner - # - if ( - (old_service_info := all_history.get(address)) - and source != old_service_info.source - and (scanner := self._sources.get(old_service_info.source)) - and scanner.scanning - and self._prefer_previous_adv_from_different_source( - old_service_info, service_info - ) - ): - # If we are rejecting the new advertisement and the device is connectable - # but not in the connectable history or the connectable source is the same - # as the new source, we need to add it to the connectable history - if connectable: - if old_connectable_service_info and ( - # If its the same as the preferred source, we are done - # as we know we prefer the old advertisement - # from the check above - (old_connectable_service_info is old_service_info) - # If the old connectable source is different from the preferred - # source, we need to check it as well to see if we prefer - # the old connectable advertisement - or ( - source != old_connectable_service_info.source - and ( - connectable_scanner := self._sources.get( - old_connectable_service_info.source - ) - ) - and connectable_scanner.scanning - and self._prefer_previous_adv_from_different_source( - old_connectable_service_info, service_info - ) - ) - ): - return - - connectable_history[address] = service_info - - return - - if connectable: - connectable_history[address] = service_info - - all_history[address] = service_info - - # Track advertisement intervals to determine when we need to - # switch adapters or mark a device as unavailable - tracker = self._advertisement_tracker - if (last_source := tracker.sources.get(address)) and last_source != source: - # Source changed, remove the old address from the tracker - tracker.async_remove_address(address) - if address not in tracker.intervals: - tracker.async_collect(service_info) - - # If the advertisement data is the same as the last time we saw it, we - # don't need to do anything else unless its connectable and we are missing - # connectable history for the device so we can make it available again - # after unavailable callbacks. - if ( - # Ensure its not a connectable device missing from connectable history - not (connectable and not old_connectable_service_info) - # Than check if advertisement data is the same - and old_service_info - and not ( - service_info.manufacturer_data != old_service_info.manufacturer_data - or service_info.service_data != old_service_info.service_data - or service_info.service_uuids != old_service_info.service_uuids - or service_info.name != old_service_info.name - ) - ): + def async_rediscover_address(self, address: str) -> None: + """Trigger discovery of devices which have already been seen.""" + self._integration_matcher.async_clear_address(address) + if service_info := self._connectable_history.get(address): + self._async_trigger_matching_discovery(service_info) return + if service_info := self._all_history.get(address): + self._async_trigger_matching_discovery(service_info) - if not connectable and old_connectable_service_info: - # Since we have a connectable path and our BleakClient will - # route any connection attempts to the connectable path, we - # mark the service_info as connectable so that the callbacks - # will be called and the device can be discovered. - service_info = BluetoothServiceInfoBleak( - name=service_info.name, - address=service_info.address, - rssi=service_info.rssi, - manufacturer_data=service_info.manufacturer_data, - service_data=service_info.service_data, - service_uuids=service_info.service_uuids, - source=service_info.source, - device=service_info.device, - advertisement=service_info.advertisement, - connectable=True, - time=service_info.time, - ) - + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: matched_domains = self._integration_matcher.match_domains(service_info) if self._debug: _LOGGER.debug( "%s: %s %s match: %s", self._async_describe_source(service_info), - address, + service_info.address, service_info.advertisement, matched_domains, ) - if (connectable or old_connectable_service_info) and ( - bleak_callbacks := self._bleak_callbacks - ): - # Bleak callbacks must get a connectable device - device = service_info.device - advertisement_data = service_info.advertisement - for callback_filters in bleak_callbacks: - _dispatch_bleak_callback(*callback_filters, device, advertisement_data) - for match in self._callback_index.match_callbacks(service_info): callback = match[CALLBACK] try: @@ -601,40 +118,33 @@ def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: service_info, ) - @hass_callback - def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str: - """Describe a source.""" - if scanner := self._sources.get(service_info.source): - description = scanner.name - else: - description = service_info.source - if service_info.connectable: - description += " [connectable]" - return description - - @hass_callback - def async_track_unavailable( - self, - callback: Callable[[BluetoothServiceInfoBleak], None], - address: str, - connectable: bool, - ) -> Callable[[], None]: - """Register a callback.""" - if connectable: - unavailable_callbacks = self._connectable_unavailable_callbacks - else: - unavailable_callbacks = self._unavailable_callbacks - unavailable_callbacks.setdefault(address, []).append(callback) - - @hass_callback - def _async_remove_callback() -> None: - unavailable_callbacks[address].remove(callback) - if not unavailable_callbacks[address]: - del unavailable_callbacks[address] + def _address_disappeared(self, address: str) -> None: + """Dismiss all discoveries for the given address.""" + self._integration_matcher.async_clear_address(address) + for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( + BluetoothServiceInfoBleak, + lambda service_info: bool(service_info.address == address), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) - return _async_remove_callback + async def async_setup(self) -> None: + """Set up the bluetooth manager.""" + await super().async_setup() + self._all_history, self._connectable_history = async_load_history_from_system( + self._bluetooth_adapters, self.storage + ) + self._cancel_logging_listener = self.hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) + seen: set[str] = set() + for address, service_info in itertools.chain( + self._connectable_history.items(), self._all_history.items() + ): + if address in seen: + continue + seen.add(address) + self._async_trigger_matching_discovery(service_info) - @hass_callback def async_register_callback( self, callback: BluetoothCallback, @@ -653,7 +163,6 @@ def async_register_callback( connectable = callback_matcher[CONNECTABLE] self._callback_index.add_callback_matcher(callback_matcher) - @hass_callback def _async_remove_callback() -> None: self._callback_index.remove_callback_matcher(callback_matcher) @@ -678,131 +187,45 @@ def _async_remove_callback() -> None: return _async_remove_callback @hass_callback - def async_ble_device_from_address( - self, address: str, connectable: bool - ) -> BLEDevice | None: - """Return the BLEDevice if present.""" - histories = self._connectable_history if connectable else self._all_history - if history := histories.get(address): - return history.device - return None - - @hass_callback - def async_address_present(self, address: str, connectable: bool) -> bool: - """Return if the address is present.""" - histories = self._connectable_history if connectable else self._all_history - return address in histories - - @hass_callback - def async_discovered_service_info( - self, connectable: bool - ) -> Iterable[BluetoothServiceInfoBleak]: - """Return all the discovered services info.""" - histories = self._connectable_history if connectable else self._all_history - return histories.values() + def async_stop(self) -> None: + """Stop the Bluetooth integration at shutdown.""" + _LOGGER.debug("Stopping bluetooth manager") + self._async_save_scanner_histories() + super().async_stop() + if self._cancel_logging_listener: + self._cancel_logging_listener() + self._cancel_logging_listener = None - @hass_callback - def async_last_service_info( - self, address: str, connectable: bool - ) -> BluetoothServiceInfoBleak | None: - """Return the last service info for an address.""" - histories = self._connectable_history if connectable else self._all_history - return histories.get(address) + def _async_save_scanner_histories(self) -> None: + """Save the scanner histories.""" + for scanner in itertools.chain( + self._connectable_scanners, self._non_connectable_scanners + ): + self._async_save_scanner_history(scanner) - def _async_trigger_matching_discovery( - self, service_info: BluetoothServiceInfoBleak - ) -> None: - """Trigger discovery for matching domains.""" - for domain in self._integration_matcher.match_domains(service_info): - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, + def _async_save_scanner_history(self, scanner: BaseHaScanner) -> None: + """Save the scanner history.""" + if isinstance(scanner, BaseHaRemoteScanner): + self.storage.async_set_advertisement_history( + scanner.source, scanner.serialize_discovered_devices() ) - @hass_callback - def async_rediscover_address(self, address: str) -> None: - """Trigger discovery of devices which have already been seen.""" - self._integration_matcher.async_clear_address(address) - if service_info := self._connectable_history.get(address): - self._async_trigger_matching_discovery(service_info) - return - if service_info := self._all_history.get(address): - self._async_trigger_matching_discovery(service_info) + def _async_unregister_scanner( + self, scanner: BaseHaScanner, unregister: CALLBACK_TYPE + ) -> None: + """Unregister a scanner.""" + unregister() + self._async_save_scanner_history(scanner) def async_register_scanner( self, scanner: BaseHaScanner, - connectable: bool, connection_slots: int | None = None, ) -> CALLBACK_TYPE: - """Register a new scanner.""" - _LOGGER.debug("Registering scanner %s", scanner.name) - if connectable: - scanners = self._connectable_scanners - else: - scanners = self._non_connectable_scanners - - def _unregister_scanner() -> None: - _LOGGER.debug("Unregistering scanner %s", scanner.name) - self._advertisement_tracker.async_remove_source(scanner.source) - scanners.remove(scanner) - del self._sources[scanner.source] - if connection_slots: - self.slot_manager.remove_adapter(scanner.adapter) - - scanners.append(scanner) - self._sources[scanner.source] = scanner - if connection_slots: - self.slot_manager.register_adapter(scanner.adapter, connection_slots) - return _unregister_scanner - - @hass_callback - def async_register_bleak_callback( - self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] - ) -> CALLBACK_TYPE: - """Register a callback.""" - callback_entry = (callback, filters) - self._bleak_callbacks.append(callback_entry) - - @hass_callback - def _remove_callback() -> None: - self._bleak_callbacks.remove(callback_entry) + """Register a scanner.""" + if isinstance(scanner, BaseHaRemoteScanner): + if history := self.storage.async_get_advertisement_history(scanner.source): + scanner.restore_discovered_devices(history) - # Replay the history since otherwise we miss devices - # that were already discovered before the callback was registered - # or we are in passive mode - for history in self._connectable_history.values(): - _dispatch_bleak_callback( - callback, filters, history.device, history.advertisement - ) - - return _remove_callback - - @hass_callback - def async_release_connection_slot(self, device: BLEDevice) -> None: - """Release a connection slot.""" - self.slot_manager.release_slot(device) - - @hass_callback - def async_allocate_connection_slot(self, device: BLEDevice) -> bool: - """Allocate a connection slot.""" - return self.slot_manager.allocate_slot(device) - - @hass_callback - def async_get_learned_advertising_interval(self, address: str) -> float | None: - """Get the learned advertising interval for a MAC address.""" - return self._intervals.get(address) - - @hass_callback - def async_get_fallback_availability_interval(self, address: str) -> float | None: - """Get the fallback availability timeout for a MAC address.""" - return self._fallback_intervals.get(address) - - @hass_callback - def async_set_fallback_availability_interval( - self, address: str, interval: float - ) -> None: - """Override the fallback availability timeout for a MAC address.""" - self._fallback_intervals[address] = interval + unregister = super().async_register_scanner(scanner, connection_slots) + return partial(self._async_unregister_scanner, scanner, unregister) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 89e6b350cad446..e7145d0385a7c3 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,10 +15,11 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.1", - "bleak-retry-connector==3.3.0", - "bluetooth-adapters==0.16.1", + "bleak-retry-connector==3.4.0", + "bluetooth-adapters==0.17.0", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.14.0", - "dbus-fast==2.14.0" + "bluetooth-data-tools==1.19.0", + "dbus-fast==2.21.0", + "habluetooth==2.0.2" ] } diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 1315d0a834a015..827006fe19d2ba 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -7,7 +7,7 @@ import re from typing import TYPE_CHECKING, Final, Generic, TypedDict, TypeVar -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from homeassistant.core import callback from homeassistant.loader import BluetoothMatcher, BluetoothMatcherOptional @@ -15,8 +15,6 @@ from .models import BluetoothCallback, BluetoothServiceInfoBleak if TYPE_CHECKING: - from collections.abc import MutableMapping - from bleak.backends.scanner import AdvertisementData @@ -97,10 +95,8 @@ def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None: self._integration_matchers = integration_matchers # Some devices use a random address so we need to use # an LRU to avoid memory issues. - self._matched: MutableMapping[str, IntegrationMatchHistory] = LRU( - MAX_REMEMBER_ADDRESSES - ) - self._matched_connectable: MutableMapping[str, IntegrationMatchHistory] = LRU( + self._matched: LRU[str, IntegrationMatchHistory] = LRU(MAX_REMEMBER_ADDRESSES) + self._matched_connectable: LRU[str, IntegrationMatchHistory] = LRU( MAX_REMEMBER_ADDRESSES ) self._index = BluetoothMatcherIndex() diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 1856ccd5994453..001a47767a1ca2 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -2,38 +2,16 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from enum import Enum -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING -from bleak import BaseBleakClient from home_assistant_bluetooth import BluetoothServiceInfoBleak -from homeassistant.util.dt import monotonic_time_coarse - if TYPE_CHECKING: - from .manager import BluetoothManager - - -MANAGER: BluetoothManager | None = None - -MONOTONIC_TIME: Final = monotonic_time_coarse - - -@dataclass(slots=True) -class HaBluetoothConnector: - """Data for how to connect a BLEDevice from a given scanner.""" - - client: type[BaseBleakClient] - source: str - can_connect: Callable[[], bool] - + from .manager import HomeAssistantBluetoothManager -class BluetoothScanningMode(Enum): - """The mode of scanning for bluetooth devices.""" - PASSIVE = "passive" - ACTIVE = "active" +MANAGER: HomeAssistantBluetoothManager | None = None BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 7dd39c140393f8..601f78d4c8d190 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -7,6 +7,8 @@ import logging from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast +from habluetooth import BluetoothScanningMode + from homeassistant import config_entries from homeassistant.const import ( ATTR_CONNECTIONS, @@ -33,11 +35,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .models import ( - BluetoothChange, - BluetoothScanningMode, - BluetoothServiceInfoBleak, - ) + from .models import BluetoothChange, BluetoothServiceInfoBleak STORAGE_KEY = "bluetooth.passive_update_processor" STORAGE_VERSION = 1 @@ -95,8 +93,11 @@ def deserialize_entity_description( descriptions_class: type[EntityDescription], data: dict[str, Any] ) -> EntityDescription: """Deserialize an entity description.""" + # pylint: disable=protected-access result: dict[str, Any] = {} - for field in cached_fields(descriptions_class): # type: ignore[arg-type] + if hasattr(descriptions_class, "_dataclass"): + descriptions_class = descriptions_class._dataclass + for field in cached_fields(descriptions_class): field_name = field.name # It would be nice if field.type returned the actual # type instead of a str so we could avoid writing this @@ -116,7 +117,7 @@ def serialize_entity_description(description: EntityDescription) -> dict[str, An as_dict = dataclasses.asdict(description) return { field.name: as_dict[field.name] - for field in cached_fields(type(description)) # type: ignore[arg-type] + for field in cached_fields(type(description)) if field.default != as_dict.get(field.name) } diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py deleted file mode 100644 index 896d9dc7958d55..00000000000000 --- a/homeassistant/components/bluetooth/scanner.py +++ /dev/null @@ -1,386 +0,0 @@ -"""The bluetooth integration.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from datetime import datetime -import logging -import platform -from typing import Any - -import bleak -from bleak import BleakError -from bleak.assigned_numbers import AdvertisementDataType -from bleak.backends.bluezdbus.advertisement_monitor import OrPattern -from bleak.backends.bluezdbus.scanner import BlueZScannerArgs -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback -from bleak_retry_connector import restore_discoveries -from bluetooth_adapters import DEFAULT_ADDRESS -from dbus_fast import InvalidMessageError - -from homeassistant.core import HomeAssistant, callback as hass_callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.package import is_docker_env - -from .base_scanner import MONOTONIC_TIME, BaseHaScanner -from .const import ( - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, - SOURCE_LOCAL, - START_TIMEOUT, -) -from .models import BluetoothScanningMode, BluetoothServiceInfoBleak -from .util import async_reset_adapter - -OriginalBleakScanner = bleak.BleakScanner - -# or_patterns is a workaround for the fact that passive scanning -# needs at least one matcher to be set. The below matcher -# will match all devices. -PASSIVE_SCANNER_ARGS = BlueZScannerArgs( - or_patterns=[ - OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), - OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), - ] -) -_LOGGER = logging.getLogger(__name__) - - -# If the adapter is in a stuck state the following errors are raised: -NEED_RESET_ERRORS = [ - "org.bluez.Error.Failed", - "org.bluez.Error.InProgress", - "org.bluez.Error.NotReady", - "not found", -] - -# When the adapter is still initializing, the scanner will raise an exception -# with org.freedesktop.DBus.Error.UnknownObject -WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"] -ADAPTER_INIT_TIME = 1.5 - -START_ATTEMPTS = 3 - -SCANNING_MODE_TO_BLEAK = { - BluetoothScanningMode.ACTIVE: "active", - BluetoothScanningMode.PASSIVE: "passive", -} - -# The minimum number of seconds to know -# the adapter has not had advertisements -# and we already tried to restart the scanner -# without success when the first time the watch -# dog hit the failure path. -SCANNER_WATCHDOG_MULTIPLE = ( - SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() -) - - -class ScannerStartError(HomeAssistantError): - """Error to indicate that the scanner failed to start.""" - - -def create_bleak_scanner( - detection_callback: AdvertisementDataCallback, - scanning_mode: BluetoothScanningMode, - adapter: str | None, -) -> bleak.BleakScanner: - """Create a Bleak scanner.""" - scanner_kwargs: dict[str, Any] = { - "detection_callback": detection_callback, - "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], - } - system = platform.system() - if system == "Linux": - # Only Linux supports multiple adapters - if adapter: - scanner_kwargs["adapter"] = adapter - if scanning_mode == BluetoothScanningMode.PASSIVE: - scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS - elif system == "Darwin": - # We want mac address on macOS - scanner_kwargs["cb"] = {"use_bdaddr": True} - _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) - - try: - return OriginalBleakScanner(**scanner_kwargs) - except (FileNotFoundError, BleakError) as ex: - raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex - - -class HaScanner(BaseHaScanner): - """Operate and automatically recover a BleakScanner. - - Multiple BleakScanner can be used at the same time - if there are multiple adapters. This is only useful - if the adapters are not located physically next to each other. - - Example use cases are usbip, a long extension cable, usb to bluetooth - over ethernet, usb over ethernet, etc. - """ - - scanner: bleak.BleakScanner - - def __init__( - self, - hass: HomeAssistant, - mode: BluetoothScanningMode, - adapter: str, - address: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - ) -> None: - """Init bluetooth discovery.""" - self.mac_address = address - source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL - super().__init__(hass, source, adapter) - self.connectable = True - self.mode = mode - self._start_stop_lock = asyncio.Lock() - self._new_info_callback = new_info_callback - self.scanning = False - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - return self.scanner.discovered_devices - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self.scanner.discovered_devices_and_advertisement_data - - @hass_callback - def async_setup(self) -> None: - """Set up the scanner.""" - self.scanner = create_bleak_scanner( - self._async_detection_callback, self.mode, self.adapter - ) - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - base_diag = await super().async_diagnostics() - return base_diag | { - "adapter": self.adapter, - } - - @hass_callback - def _async_detection_callback( - self, - device: BLEDevice, - advertisement_data: AdvertisementData, - ) -> None: - """Call the callback when an advertisement is received. - - Currently this is used to feed the callbacks into the - central manager. - """ - callback_time = MONOTONIC_TIME() - if ( - advertisement_data.local_name - or advertisement_data.manufacturer_data - or advertisement_data.service_data - or advertisement_data.service_uuids - ): - # Don't count empty advertisements - # as the adapter is in a failure - # state if all the data is empty. - self._last_detection = callback_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=advertisement_data.local_name or device.name or device.address, - address=device.address, - rssi=advertisement_data.rssi, - manufacturer_data=advertisement_data.manufacturer_data, - service_data=advertisement_data.service_data, - service_uuids=advertisement_data.service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=True, - time=callback_time, - ) - ) - - async def async_start(self) -> None: - """Start bluetooth scanner.""" - async with self._start_stop_lock: - await self._async_start() - - async def _async_start(self) -> None: - """Start bluetooth scanner under the lock.""" - for attempt in range(START_ATTEMPTS): - _LOGGER.debug( - "%s: Starting bluetooth discovery attempt: (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - try: - async with asyncio.timeout(START_TIMEOUT): - await self.scanner.start() # type: ignore[no-untyped-call] - except InvalidMessageError as ex: - _LOGGER.debug( - "%s: Invalid DBus message received: %s", - self.name, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Invalid DBus message received: {ex}; " - "try restarting `dbus`" - ) from ex - except BrokenPipeError as ex: - _LOGGER.debug( - "%s: DBus connection broken: %s", self.name, ex, exc_info=True - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth`, `dbus`, and finally the docker container" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth` and `dbus`" - ) from ex - except FileNotFoundError as ex: - _LOGGER.debug( - "%s: FileNotFoundError while starting bluetooth: %s", - self.name, - ex, - exc_info=True, - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus service not found; docker config may " - "be missing `-v /run/dbus:/run/dbus:ro`: {ex}" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus service not found; make sure the DBus socket " - f"is available to Home Assistant: {ex}" - ) from ex - except asyncio.TimeoutError as ex: - if attempt == 0: - await self._async_reset_adapter() - continue - raise ScannerStartError( - f"{self.name}: Timed out starting Bluetooth after" - f" {START_TIMEOUT} seconds" - ) from ex - except BleakError as ex: - error_str = str(ex) - if attempt == 0: - if any( - needs_reset_error in error_str - for needs_reset_error in NEED_RESET_ERRORS - ): - await self._async_reset_adapter() - continue - if attempt != START_ATTEMPTS - 1: - # If we are not out of retry attempts, and the - # adapter is still initializing, wait a bit and try again. - if any( - wait_error in error_str - for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS - ): - _LOGGER.debug( - "%s: Waiting for adapter to initialize; attempt (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - await asyncio.sleep(ADAPTER_INIT_TIME) - continue - - _LOGGER.debug( - "%s: BleakError while starting bluetooth; attempt: (%s/%s): %s", - self.name, - attempt + 1, - START_ATTEMPTS, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Failed to start Bluetooth: {ex}" - ) from ex - - # Everything is fine, break out of the loop - break - - self.scanning = True - self._async_setup_scanner_watchdog() - await restore_discoveries(self.scanner, self.adapter) - - @hass_callback - def _async_scanner_watchdog(self, now: datetime) -> None: - """Check if the scanner is running.""" - if not self._async_watchdog_triggered(): - return - if self._start_stop_lock.locked(): - _LOGGER.debug( - "%s: Scanner is already restarting, deferring restart", - self.name, - ) - return - _LOGGER.info( - "%s: Bluetooth scanner has gone quiet for %ss, restarting", - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - # Immediately mark the scanner as not scanning - # since the restart task will have to wait for the lock - self.scanning = False - self.hass.async_create_task(self._async_restart_scanner()) - - async def _async_restart_scanner(self) -> None: - """Restart the scanner.""" - async with self._start_stop_lock: - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - # Stop the scanner but not the watchdog - # since we want to try again later if it's still quiet - await self._async_stop_scanner() - # If there have not been any valid advertisements, - # or the watchdog has hit the failure path multiple times, - # do the reset. - if ( - self._start_time == self._last_detection - or time_since_last_detection > SCANNER_WATCHDOG_MULTIPLE - ): - await self._async_reset_adapter() - try: - await self._async_start() - except ScannerStartError as ex: - _LOGGER.exception( - "%s: Failed to restart Bluetooth scanner: %s", - self.name, - ex, - ) - - async def _async_reset_adapter(self) -> None: - """Reset the adapter.""" - # There is currently nothing the user can do to fix this - # so we log at debug level. If we later come up with a repair - # strategy, we will change this to raise a repair issue as well. - _LOGGER.debug("%s: adapter stopped responding; executing reset", self.name) - result = await async_reset_adapter(self.adapter, self.mac_address) - _LOGGER.debug("%s: adapter reset result: %s", self.name, result) - - async def async_stop(self) -> None: - """Stop bluetooth scanner.""" - async with self._start_stop_lock: - self._async_stop_scanner_watchdog() - await self._async_stop_scanner() - - async def _async_stop_scanner(self) -> None: - """Stop bluetooth discovery under the lock.""" - self.scanning = False - _LOGGER.debug("%s: Stopping bluetooth discovery", self.name) - try: - await self.scanner.stop() # type: ignore[no-untyped-call] - except BleakError as ex: - # This is not fatal, and they may want to reload - # the config entry to restart the scanner if they - # change the bluetooth dongle. - _LOGGER.error("%s: Error stopping scanner: %s", self.name, ex) diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 295e84d44815b5..2d495a0659cbcf 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -4,6 +4,8 @@ from abc import ABC, abstractmethod import logging +from habluetooth import BluetoothScanningMode + from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from .api import ( @@ -13,7 +15,7 @@ async_track_unavailable, ) from .match import BluetoothCallbackMatcher -from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak +from .models import BluetoothChange, BluetoothServiceInfoBleak class BasePassiveBluetoothCoordinator(ABC): diff --git a/homeassistant/components/bluetooth/usage.py b/homeassistant/components/bluetooth/usage.py deleted file mode 100644 index d89f0b5b684280..00000000000000 --- a/homeassistant/components/bluetooth/usage.py +++ /dev/null @@ -1,51 +0,0 @@ -"""bluetooth usage utility to handle multiple instances.""" - -from __future__ import annotations - -import bleak -from bleak.backends.service import BleakGATTServiceCollection -import bleak_retry_connector - -from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper - -ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner -ORIGINAL_BLEAK_CLIENT = bleak.BleakClient -ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = ( - bleak_retry_connector.BleakClientWithServiceCache -) -ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient - - -def install_multiple_bleak_catcher() -> None: - """Wrap the bleak classes to return the shared instance. - - In case multiple instances are detected. - """ - bleak.BleakScanner = HaBleakScannerWrapper # type: ignore[misc, assignment] - bleak.BleakClient = HaBleakClientWrapper # type: ignore[misc] - bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache # type: ignore[misc,assignment] # noqa: E501 - bleak_retry_connector.BleakClient = HaBleakClientWrapper # type: ignore[misc] # noqa: E501 - - -def uninstall_multiple_bleak_catcher() -> None: - """Unwrap the bleak classes.""" - bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER # type: ignore[misc] - bleak.BleakClient = ORIGINAL_BLEAK_CLIENT # type: ignore[misc] - bleak_retry_connector.BleakClientWithServiceCache = ( # type: ignore[misc] - ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE - ) - bleak_retry_connector.BleakClient = ( # type: ignore[misc] - ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT - ) - - -class HaBleakClientWithServiceCache(HaBleakClientWrapper): - """A BleakClient that implements service caching.""" - - def set_cached_services(self, services: BleakGATTServiceCollection | None) -> None: - """Set the cached services. - - No longer used since bleak 0.17+ has service caching built-in. - - This was only kept for backwards compatibility. - """ diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index e78eb51a38c115..d531e46f91190d 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -2,10 +2,9 @@ from __future__ import annotations from bluetooth_adapters import BluetoothAdapters -from bluetooth_auto_recovery import recover_adapter +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import callback -from homeassistant.util.dt import monotonic_time_coarse from .models import BluetoothServiceInfoBleak from .storage import BluetoothStorage @@ -69,11 +68,3 @@ def async_load_history_from_system( connectable_loaded_history[address] = service_info return all_loaded_history, connectable_loaded_history - - -async def async_reset_adapter(adapter: str | None, mac_address: str) -> bool | None: - """Reset the adapter.""" - if adapter and adapter.startswith("hci"): - adapter_id = int(adapter[3:]) - return await recover_adapter(adapter_id, mac_address) - return False diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py deleted file mode 100644 index bfcee9d25df560..00000000000000 --- a/homeassistant/components/bluetooth/wrappers.py +++ /dev/null @@ -1,390 +0,0 @@ -"""Bleak wrappers for bluetooth.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -import contextlib -from dataclasses import dataclass -from functools import partial -import inspect -import logging -from typing import TYPE_CHECKING, Any, Final - -from bleak import BleakClient, BleakError -from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import ( - AdvertisementData, - AdvertisementDataCallback, - BaseBleakScanner, -) -from bleak_retry_connector import ( - NO_RSSI_VALUE, - ble_device_description, - clear_cache, - device_source, -) - -from homeassistant.core import CALLBACK_TYPE, callback as hass_callback -from homeassistant.helpers.frame import report - -from . import models -from .base_scanner import BaseHaScanner, BluetoothScannerDevice - -FILTER_UUIDS: Final = "UUIDs" -_LOGGER = logging.getLogger(__name__) - - -if TYPE_CHECKING: - from .manager import BluetoothManager - - -@dataclass(slots=True) -class _HaWrappedBleakBackend: - """Wrap bleak backend to make it usable by Home Assistant.""" - - device: BLEDevice - scanner: BaseHaScanner - client: type[BaseBleakClient] - source: str | None - - -class HaBleakScannerWrapper(BaseBleakScanner): - """A wrapper that uses the single instance.""" - - def __init__( - self, - *args: Any, - detection_callback: AdvertisementDataCallback | None = None, - service_uuids: list[str] | None = None, - **kwargs: Any, - ) -> None: - """Initialize the BleakScanner.""" - self._detection_cancel: CALLBACK_TYPE | None = None - self._mapped_filters: dict[str, set[str]] = {} - self._advertisement_data_callback: AdvertisementDataCallback | None = None - self._background_tasks: set[asyncio.Task] = set() - remapped_kwargs = { - "detection_callback": detection_callback, - "service_uuids": service_uuids or [], - **kwargs, - } - self._map_filters(*args, **remapped_kwargs) - super().__init__( - detection_callback=detection_callback, service_uuids=service_uuids or [] - ) - - @classmethod - async def discover(cls, timeout: float = 5.0, **kwargs: Any) -> list[BLEDevice]: - """Discover devices.""" - assert models.MANAGER is not None - return list(models.MANAGER.async_discovered_devices(True)) - - async def stop(self, *args: Any, **kwargs: Any) -> None: - """Stop scanning for devices.""" - - async def start(self, *args: Any, **kwargs: Any) -> None: - """Start scanning for devices.""" - - def _map_filters(self, *args: Any, **kwargs: Any) -> bool: - """Map the filters.""" - mapped_filters = {} - if filters := kwargs.get("filters"): - if filter_uuids := filters.get(FILTER_UUIDS): - mapped_filters[FILTER_UUIDS] = set(filter_uuids) - else: - _LOGGER.warning("Only %s filters are supported", FILTER_UUIDS) - if service_uuids := kwargs.get("service_uuids"): - mapped_filters[FILTER_UUIDS] = set(service_uuids) - if mapped_filters == self._mapped_filters: - return False - self._mapped_filters = mapped_filters - return True - - def set_scanning_filter(self, *args: Any, **kwargs: Any) -> None: - """Set the filters to use.""" - if self._map_filters(*args, **kwargs): - self._setup_detection_callback() - - def _cancel_callback(self) -> None: - """Cancel callback.""" - if self._detection_cancel: - self._detection_cancel() - self._detection_cancel = None - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - assert models.MANAGER is not None - return list(models.MANAGER.async_discovered_devices(True)) - - def register_detection_callback( - self, callback: AdvertisementDataCallback | None - ) -> Callable[[], None]: - """Register a detection callback. - - The callback is called when a device is discovered or has a property changed. - - This method takes the callback and registers it with the long running scanner. - """ - self._advertisement_data_callback = callback - self._setup_detection_callback() - assert self._detection_cancel is not None - return self._detection_cancel - - def _setup_detection_callback(self) -> None: - """Set up the detection callback.""" - if self._advertisement_data_callback is None: - return - callback = self._advertisement_data_callback - self._cancel_callback() - super().register_detection_callback(self._advertisement_data_callback) - assert models.MANAGER is not None - - if not inspect.iscoroutinefunction(callback): - detection_callback = callback - else: - - def detection_callback( - ble_device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - task = asyncio.create_task(callback(ble_device, advertisement_data)) - self._background_tasks.add(task) - task.add_done_callback(self._background_tasks.discard) - - self._detection_cancel = models.MANAGER.async_register_bleak_callback( - detection_callback, self._mapped_filters - ) - - def __del__(self) -> None: - """Delete the BleakScanner.""" - if self._detection_cancel: - # Nothing to do if event loop is already closed - with contextlib.suppress(RuntimeError): - asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel) - - -def _rssi_sorter_with_connection_failure_penalty( - device: BluetoothScannerDevice, - connection_failure_count: dict[BaseHaScanner, int], - rssi_diff: int, -) -> float: - """Get a sorted list of scanner, device, advertisement data. - - Adjusting for previous connection failures. - - When a connection fails, we want to try the next best adapter so we - apply a penalty to the RSSI value to make it less likely to be chosen - for every previous connection failure. - - We use the 51% of the RSSI difference between the first and second - best adapter as the penalty. This ensures we will always try the - best adapter twice before moving on to the next best adapter since - the first failure may be a transient service resolution issue. - """ - base_rssi = device.advertisement.rssi or NO_RSSI_VALUE - if connect_failures := connection_failure_count.get(device.scanner): - if connect_failures > 1 and not rssi_diff: - rssi_diff = 1 - return base_rssi - (rssi_diff * connect_failures * 0.51) - return base_rssi - - -class HaBleakClientWrapper(BleakClient): - """Wrap the BleakClient to ensure it does not shutdown our scanner. - - If an address is passed into BleakClient instead of a BLEDevice, - bleak will quietly start a new scanner under the hood to resolve - the address. This can cause a conflict with our scanner. We need - to handle translating the address to the BLEDevice in this case - to avoid the whole stack from getting stuck in an in progress state - when an integration does this. - """ - - def __init__( # pylint: disable=super-init-not-called - self, - address_or_ble_device: str | BLEDevice, - disconnected_callback: Callable[[BleakClient], None] | None = None, - *args: Any, - timeout: float = 10.0, - **kwargs: Any, - ) -> None: - """Initialize the BleakClient.""" - if isinstance(address_or_ble_device, BLEDevice): - self.__address = address_or_ble_device.address - else: - report( - "attempted to call BleakClient with an address instead of a BLEDevice", - exclude_integrations={"bluetooth"}, - error_if_core=False, - ) - self.__address = address_or_ble_device - self.__disconnected_callback = disconnected_callback - self.__timeout = timeout - self.__connect_failures: dict[BaseHaScanner, int] = {} - self._backend: BaseBleakClient | None = None # type: ignore[assignment] - - @property - def is_connected(self) -> bool: - """Return True if the client is connected to a device.""" - return self._backend is not None and self._backend.is_connected - - async def clear_cache(self) -> bool: - """Clear the GATT cache.""" - if self._backend is not None and hasattr(self._backend, "clear_cache"): - return await self._backend.clear_cache() # type: ignore[no-any-return] - return await clear_cache(self.__address) - - def set_disconnected_callback( - self, - callback: Callable[[BleakClient], None] | None, - **kwargs: Any, - ) -> None: - """Set the disconnect callback.""" - self.__disconnected_callback = callback - if self._backend: - self._backend.set_disconnected_callback( - self._make_disconnected_callback(callback), - **kwargs, - ) - - def _make_disconnected_callback( - self, callback: Callable[[BleakClient], None] | None - ) -> Callable[[], None] | None: - """Make the disconnected callback. - - https://github.com/hbldh/bleak/pull/1256 - The disconnected callback needs to get the top level - BleakClientWrapper instance, not the backend instance. - - The signature of the callback for the backend is: - Callable[[], None] - - To make this work we need to wrap the callback in a partial - that passes the BleakClientWrapper instance as the first - argument. - """ - return None if callback is None else partial(callback, self) - - async def connect(self, **kwargs: Any) -> bool: - """Connect to the specified GATT server.""" - assert models.MANAGER is not None - manager = models.MANAGER - if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("%s: Looking for backend to connect", self.__address) - wrapped_backend = self._async_get_best_available_backend_and_device(manager) - device = wrapped_backend.device - scanner = wrapped_backend.scanner - self._backend = wrapped_backend.client( - device, - disconnected_callback=self._make_disconnected_callback( - self.__disconnected_callback - ), - timeout=self.__timeout, - hass=manager.hass, - ) - if debug_logging: - # Only lookup the description if we are going to log it - description = ble_device_description(device) - _, adv = scanner.discovered_devices_and_advertisement_data[device.address] - rssi = adv.rssi - _LOGGER.debug( - "%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi - ) - connected = None - try: - connected = await super().connect(**kwargs) - finally: - # If we failed to connect and its a local adapter (no source) - # we release the connection slot - if not connected: - self.__connect_failures[scanner] = ( - self.__connect_failures.get(scanner, 0) + 1 - ) - if not wrapped_backend.source: - manager.async_release_connection_slot(device) - - if debug_logging: - _LOGGER.debug( - "%s: Connected via %s (last rssi: %s)", description, scanner.name, rssi - ) - return connected - - @hass_callback - def _async_get_backend_for_ble_device( - self, manager: BluetoothManager, scanner: BaseHaScanner, ble_device: BLEDevice - ) -> _HaWrappedBleakBackend | None: - """Get the backend for a BLEDevice.""" - if not (source := device_source(ble_device)): - # If client is not defined in details - # its the client for this platform - if not manager.async_allocate_connection_slot(ble_device): - return None - cls = get_platform_client_backend_type() - return _HaWrappedBleakBackend(ble_device, scanner, cls, source) - - # Make sure the backend can connect to the device - # as some backends have connection limits - if not scanner.connector or not scanner.connector.can_connect(): - return None - - return _HaWrappedBleakBackend( - ble_device, scanner, scanner.connector.client, source - ) - - @hass_callback - def _async_get_best_available_backend_and_device( - self, manager: BluetoothManager - ) -> _HaWrappedBleakBackend: - """Get a best available backend and device for the given address. - - This method will return the backend with the best rssi - that has a free connection slot. - """ - address = self.__address - devices = manager.async_scanner_devices_by_address(self.__address, True) - sorted_devices = sorted( - devices, - key=lambda device: device.advertisement.rssi or NO_RSSI_VALUE, - reverse=True, - ) - - # If we have connection failures we adjust the rssi sorting - # to prefer the adapter/scanner with the less failures so - # we don't keep trying to connect with an adapter - # that is failing - if self.__connect_failures and len(sorted_devices) > 1: - # We use the rssi diff between to the top two - # to adjust the rssi sorter so that each failure - # will reduce the rssi sorter by the diff amount - rssi_diff = ( - sorted_devices[0].advertisement.rssi - - sorted_devices[1].advertisement.rssi - ) - adjusted_rssi_sorter = partial( - _rssi_sorter_with_connection_failure_penalty, - connection_failure_count=self.__connect_failures, - rssi_diff=rssi_diff, - ) - sorted_devices = sorted( - devices, - key=adjusted_rssi_sorter, - reverse=True, - ) - - for device in sorted_devices: - if backend := self._async_get_backend_for_ble_device( - manager, device.scanner, device.ble_device - ): - return backend - - raise BleakError( - "No backend with an available connection slot that can reach address" - f" {address} was found" - ) - - async def disconnect(self) -> bool: - """Disconnect from the device.""" - if self._backend is None: - return True - return await self._backend.disconnect() diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 0e3750de08517e..29c4d61e9f7192 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -109,14 +109,14 @@ def _format_cbs_report( return result -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[MyBMWVehicle], bool] -@dataclass +@dataclass(frozen=True) class BMWBinarySensorEntityDescription( BinarySensorEntityDescription, BMWRequiredKeysMixin ): diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index c3f066610a9509..f2a123fe4a8af2 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -25,14 +25,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" remote_function: Callable[[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]] -@dataclass +@dataclass(frozen=True) class BMWButtonEntityDescription(ButtonEntityDescription, BMWRequiredKeysMixin): """Class describing BMW button entities.""" diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 2634c6069c9f6e..4e811d48647b78 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -32,8 +32,6 @@ def __init__(self, hass: HomeAssistant, *, entry: ConfigEntry) -> None: entry.data[CONF_PASSWORD], get_region_from_name(entry.data[CONF_REGION]), observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), - # Force metric system as BMW API apparently only returns metric values now - use_metric_units=True, ) self.read_only = entry.options[CONF_READ_ONLY] self._entry = entry diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index b5652694120404..854a2f87410957 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.2"] + "requirements": ["bimmer-connected[china]==0.14.6"] } diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index f37f7627140c5f..0ed732e1dcb966 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" @@ -34,7 +34,7 @@ class BMWRequiredKeysMixin: remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): """Describes BMW number entity.""" diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 3467322a4af051..8823c6552cc555 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" @@ -30,7 +30,7 @@ class BMWRequiredKeysMixin: remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): """Describes BMW sensor entity.""" @@ -44,7 +44,8 @@ class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): 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/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 62854badb20e19..d486c41ae56ee5 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWSensorEntityDescription(SensorEntityDescription): """Describes BMW sensor entity.""" diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index 298338dc9fa339..e4ce0ba81ff6e7 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class BMWRequiredKeysMixin: remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): """Describes BMW switch entity.""" diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 1109cf0d311470..273ef837f6e876 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -21,7 +21,7 @@ STEP_SIZE = 10 -@dataclass +@dataclass(frozen=True) class BondButtonEntityDescriptionMixin: """Mixin to describe a Bond Button entity.""" @@ -29,7 +29,7 @@ class BondButtonEntityDescriptionMixin: argument: int | None -@dataclass +@dataclass(frozen=True) class BondButtonEntityDescription( ButtonEntityDescription, BondButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index bc6235cb219cd9..403e0ae01e6d28 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -21,10 +21,10 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity @@ -72,6 +72,14 @@ def __init__( super().__init__(hub, device, bpup_subs) if self._device.has_action(Action.BREEZE_ON): self._attr_preset_modes = [PRESET_MODE_BREEZE] + features = FanEntityFeature(0) + if self._device.supports_speed(): + features |= FanEntityFeature.SET_SPEED + if self._device.supports_direction(): + features |= FanEntityFeature.DIRECTION + if self._device.has_action(Action.BREEZE_ON): + features |= FanEntityFeature.PRESET_MODE + self._attr_supported_features = features def _apply_state(self) -> None: state = self._device.state @@ -81,18 +89,6 @@ def _apply_state(self) -> None: breeze = state.get("breeze", [0, 0, 0]) self._attr_preset_mode = PRESET_MODE_BREEZE if breeze[0] else None - @property - def supported_features(self) -> FanEntityFeature: - """Flag supported features.""" - features = FanEntityFeature(0) - if self._device.supports_speed(): - features |= FanEntityFeature.SET_SPEED - if self._device.supports_direction(): - features |= FanEntityFeature.DIRECTION - if self._device.has_action(Action.BREEZE_ON): - features |= FanEntityFeature.PRESET_MODE - return features - @property def _speed_range(self) -> tuple[int, int]: """Return the range of speeds.""" @@ -199,10 +195,6 @@ async def async_turn_on( 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 4c7c224bc44edf..8986905c6eeb2b 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/manifest.json b/homeassistant/components/bosch_shc/manifest.json index 9fd1055dd60403..e29865153b3b6f 100644 --- a/homeassistant/components/bosch_shc/manifest.json +++ b/homeassistant/components/bosch_shc/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/bosch_shc", "iot_class": "local_push", "loggers": ["boschshcpy"], - "requirements": ["boschshcpy==0.2.57"], + "requirements": ["boschshcpy==0.2.75"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 90688e1373ff5d..88eb817bbd954f 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/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 25af0628780858..03d3ba2f6a9e70 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -29,7 +29,7 @@ from .entity import SHCEntity -@dataclass +@dataclass(frozen=True) class SHCSwitchRequiredKeysMixin: """Mixin for SHC switch required keys.""" @@ -38,7 +38,7 @@ class SHCSwitchRequiredKeysMixin: should_poll: bool -@dataclass +@dataclass(frozen=True) class SHCSwitchEntityDescription( SwitchEntityDescription, SHCSwitchRequiredKeysMixin, diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 1f6c9961c5176b..eb3d2d8797fab2 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -19,14 +19,14 @@ from .entity import BraviaTVEntity -@dataclass +@dataclass(frozen=True) class BraviaTVButtonDescriptionMixin: """Mixin to describe a Bravia TV Button entity.""" press_action: Callable[[BraviaTVCoordinator], Coroutine] -@dataclass +@dataclass(frozen=True) class BraviaTVButtonDescription( ButtonEntityDescription, BraviaTVButtonDescriptionMixin ): diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 3fb6e6b3b4027a..fd72203b249703 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -12,7 +12,7 @@ from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN +from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -22,7 +22,6 @@ ATTR_CID, ATTR_MAC, ATTR_MODEL, - CONF_CLIENT_ID, CONF_NICKNAME, CONF_USE_PSK, DOMAIN, diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index 34b621802f9eba..aff02aa9e8baf3 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -9,7 +9,6 @@ ATTR_MANUFACTURER: Final = "Sony" ATTR_MODEL: Final = "model" -CONF_CLIENT_ID: Final = "client_id" CONF_NICKNAME: Final = "nickname" CONF_USE_PSK: Final = "use_psk" diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 20b30d1dd11ee1..59219a34eb78ed 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -19,14 +19,13 @@ ) from homeassistant.components.media_player import MediaType -from homeassistant.const import CONF_PIN +from homeassistant.const import CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - CONF_CLIENT_ID, CONF_NICKNAME, CONF_USE_PSK, DOMAIN, @@ -43,7 +42,7 @@ def catch_braviatv_errors( - func: Callable[Concatenate[_BraviaTVCoordinatorT, _P], Awaitable[None]] + func: Callable[Concatenate[_BraviaTVCoordinatorT, _P], Awaitable[None]], ) -> Callable[Concatenate[_BraviaTVCoordinatorT, _P], Coroutine[Any, Any, None]]: """Catch Bravia errors.""" diff --git a/homeassistant/components/braviatv/diagnostics.py b/homeassistant/components/braviatv/diagnostics.py new file mode 100644 index 00000000000000..f1822b545e9bcc --- /dev/null +++ b/homeassistant/components/braviatv/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for BraviaTV.""" +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC, CONF_PIN +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import BraviaTVCoordinator + +TO_REDACT = {CONF_MAC, CONF_PIN, "macAddr"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: BraviaTVCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + device_info = await coordinator.client.get_system_info() + + diagnostics_data = { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "device_info": async_redact_data(device_info, TO_REDACT), + } + + return diagnostics_data diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index 8f8e728cb9d049..4b28fa91d745fe 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 00000000000000..6937d6bb0da89f --- /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 c1ccc5ec954af1..2b9e8787a434e0 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -4,6 +4,7 @@ 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 5778520e530a0b..7fd925a2ff4b14 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 87567bcb7b173a..335984d1ebe40a 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 da8461bf90fc49..10ac4df4bb847a 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/__init__.py b/homeassistant/components/brother/__init__.py index 0f8f94c73c4c30..27ac97a27dc8c6 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -4,23 +4,18 @@ from asyncio import timeout from datetime import timedelta import logging -import sys -from typing import Any + +from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DATA_CONFIG_ENTRY, DOMAIN, SNMP from .utils import get_snmp_engine -if sys.version_info < (3, 12): - from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError -else: - BrotherSensors = Any - PLATFORMS = [Platform.SENSOR] SCAN_INTERVAL = timedelta(seconds=30) @@ -30,10 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Brother from a config entry.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "Brother Printer is not supported on Python 3.12. Please use Python 3.11." - ) host = entry.data[CONF_HOST] printer_type = entry.data[CONF_TYPE] diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index cba44b68c6ac44..06b8574dbb47c5 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==2.3.0"], + "requirements": ["brother==3.0.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index e9554d84207e61..27e4b7fd715255 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -35,14 +35,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BrotherSensorRequiredKeysMixin: """Class for Brother entity required keys.""" value: Callable[[BrotherSensors], StateType | datetime] -@dataclass +@dataclass(frozen=True) class BrotherSensorEntityDescription( SensorEntityDescription, BrotherSensorRequiredKeysMixin ): diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index e24c941c5142b9..0d8f4f4eedfd95 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/brother/utils.py b/homeassistant/components/brother/utils.py index cd472b9b754691..47b7ae31a67695 100644 --- a/homeassistant/components/brother/utils.py +++ b/homeassistant/components/brother/utils.py @@ -2,7 +2,9 @@ from __future__ import annotations import logging -import sys + +import pysnmp.hlapi.asyncio as hlapi +from pysnmp.hlapi.asyncio.cmdgen import lcd from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -10,10 +12,6 @@ from .const import DOMAIN, SNMP -if sys.version_info < (3, 12): - import pysnmp.hlapi.asyncio as hlapi - from pysnmp.hlapi.asyncio.cmdgen import lcd - _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/brottsplatskartan/config_flow.py b/homeassistant/components/brottsplatskartan/config_flow.py index ac9a764179e769..39c7421fa92777 100644 --- a/homeassistant/components/brottsplatskartan/config_flow.py +++ b/homeassistant/components/brottsplatskartan/config_flow.py @@ -4,6 +4,7 @@ from typing import Any import uuid +from brottsplatskartan import AREAS import voluptuous as vol from homeassistant import config_entries @@ -11,7 +12,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector -from .const import AREAS, CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN +from .const import CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN DATA_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/brottsplatskartan/const.py b/homeassistant/components/brottsplatskartan/const.py index b53a39755a6e88..94b4a7e72801cd 100644 --- a/homeassistant/components/brottsplatskartan/const.py +++ b/homeassistant/components/brottsplatskartan/const.py @@ -12,27 +12,3 @@ CONF_AREA = "area" CONF_APP_ID = "app_id" DEFAULT_NAME = "Brottsplatskartan" - -AREAS = [ - "Blekinge län", - "Dalarnas län", - "Gotlands län", - "Gävleborgs län", - "Hallands län", - "Jämtlands län", - "Jönköpings län", - "Kalmar län", - "Kronobergs län", - "Norrbottens län", - "Skåne län", - "Stockholms län", - "Södermanlands län", - "Uppsala län", - "Värmlands län", - "Västerbottens län", - "Västernorrlands län", - "Västmanlands län", - "Västra Götalands län", - "Örebro län", - "Östergötlands län", -] diff --git a/homeassistant/components/brottsplatskartan/manifest.json b/homeassistant/components/brottsplatskartan/manifest.json index 14c4a5e39c290d..0a386094bae757 100644 --- a/homeassistant/components/brottsplatskartan/manifest.json +++ b/homeassistant/components/brottsplatskartan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/brottsplatskartan", "iot_class": "cloud_polling", "loggers": ["brottsplatskartan"], - "requirements": ["brottsplatskartan==0.0.1"] + "requirements": ["brottsplatskartan==1.0.5"] } diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index df17832f695102..b30b31be985fdf 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -3,6 +3,7 @@ from collections import defaultdict from datetime import timedelta +from typing import Literal from brottsplatskartan import ATTRIBUTION, BrottsplatsKartan @@ -29,9 +30,11 @@ async def async_setup_entry( app = entry.data[CONF_APP_ID] name = entry.title - bpk = BrottsplatsKartan(app=app, area=area, latitude=latitude, longitude=longitude) + bpk = BrottsplatsKartan( + app=app, areas=[area] if area else None, latitude=latitude, longitude=longitude + ) - async_add_entities([BrottsplatskartanSensor(bpk, name, entry.entry_id)], True) + async_add_entities([BrottsplatskartanSensor(bpk, name, entry.entry_id, area)], True) class BrottsplatskartanSensor(SensorEntity): @@ -41,9 +44,12 @@ class BrottsplatskartanSensor(SensorEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, bpk: BrottsplatsKartan, name: str, entry_id: str) -> None: + def __init__( + self, bpk: BrottsplatsKartan, name: str, entry_id: str, area: str | None + ) -> None: """Initialize the Brottsplatskartan sensor.""" self._brottsplatskartan = bpk + self._area = area self._attr_unique_id = entry_id self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -56,12 +62,19 @@ def update(self) -> None: """Update device state.""" incident_counts: defaultdict[str, int] = defaultdict(int) - incidents = self._brottsplatskartan.get_incidents() + get_incidents: dict[str, list] | Literal[ + False + ] = self._brottsplatskartan.get_incidents() - if incidents is False: + if get_incidents is False: LOGGER.debug("Problems fetching incidents") return + if self._area: + incidents = get_incidents.get(self._area) or [] + else: + incidents = get_incidents.get("latlng") or [] + for incident in incidents: if (incident_type := incident.get("title_type")) is not None: incident_counts[incident_type] += 1 diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 39eab6e7e0a5f1..609d5ab6e83a53 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/strings.json b/homeassistant/components/bsblan/strings.json index 0693f3fb8ea0fc..689d1f893d3c05 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/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index a7729cc256e9fe..be64f01966fedc 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.2.0"] + "requirements": ["bthome-ble==3.3.1"] } diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 439921928d65e1..1963041bccad37 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -10,19 +10,13 @@ from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import ( - CONF_COUNTRY, - CONF_DELTA, - DEFAULT_COUNTRY, - DEFAULT_DELTA, - DEFAULT_DIMENSION, -) +from .const import CONF_DELTA, DEFAULT_COUNTRY, DEFAULT_DELTA, DEFAULT_DIMENSION _LOGGER = logging.getLogger(__name__) @@ -40,7 +34,9 @@ async def async_setup_entry( config = entry.data options = entry.options - country = options.get(CONF_COUNTRY, config.get(CONF_COUNTRY, DEFAULT_COUNTRY)) + country = options.get( + CONF_COUNTRY_CODE, config.get(CONF_COUNTRY_CODE, DEFAULT_COUNTRY) + ) delta = options.get(CONF_DELTA, config.get(CONF_DELTA, DEFAULT_DELTA)) diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index 4a81a774b4fb75..1e77693f7fba11 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -8,7 +8,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector @@ -20,7 +20,6 @@ ) from .const import ( - CONF_COUNTRY, CONF_DELTA, CONF_TIMEFRAME, DEFAULT_COUNTRY, @@ -32,7 +31,9 @@ OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_COUNTRY, default=DEFAULT_COUNTRY): selector.CountrySelector( + vol.Optional( + CONF_COUNTRY_CODE, default=DEFAULT_COUNTRY + ): selector.CountrySelector( selector.CountrySelectorConfig(countries=SUPPORTED_COUNTRY_CODES) ), vol.Optional(CONF_DELTA, default=DEFAULT_DELTA): selector.NumberSelector( diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index 718812c5c731fc..c82970ed318837 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -8,7 +8,6 @@ DEFAULT_DELTA = 600 CONF_DELTA = "delta" -CONF_COUNTRY = "country_code" CONF_TIMEFRAME = "timeframe" SUPPORTED_COUNTRY_CODES = ["NL", "BE"] diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 901acdcdec1989..358348a807763c 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -1,11 +1,10 @@ """Component to pressing a button as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import datetime, timedelta from enum import StrEnum import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -23,6 +22,11 @@ from .const import DOMAIN, SERVICE_PRESS +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -73,14 +77,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class ButtonEntityDescription(EntityDescription): +class ButtonEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes button entities.""" device_class: ButtonDeviceClass | None = None -class ButtonEntity(RestoreEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", +} + + +class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Button entity.""" entity_description: ButtonEntityDescription @@ -96,7 +104,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @property + @cached_property def device_class(self) -> ButtonDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index f923604904863f..fa89d6acc384df 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -11,7 +11,11 @@ async def async_get_calendars( hass: HomeAssistant, client: caldav.DAVClient, component: str ) -> list[caldav.Calendar]: """Get all calendars that support the specified component.""" - calendars = await hass.async_add_executor_job(client.principal().calendars) + + 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) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index a7365515758e1d..619523ae7a1528 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.3.6"] + "requirements": ["caldav==1.3.8"] } diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index 887f760399b91e..90380805c31f0f 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -1,16 +1,27 @@ """CalDAV todo platform.""" from __future__ import annotations -from datetime import timedelta +import asyncio +from datetime import date, datetime, timedelta from functools import partial import logging +from typing import Any, cast import caldav - -from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity +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 @@ -26,6 +37,10 @@ "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( @@ -57,6 +72,12 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | 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, @@ -64,6 +85,8 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: get_attr_value(todo, "status") or "", TodoItemStatus.NEEDS_ACTION, ), + due=due, + description=get_attr_value(todo, "description"), ) @@ -71,6 +94,14 @@ 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.""" @@ -92,3 +123,76 @@ async def async_update(self) -> None: 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.""" + 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: + item_data["due"] = due + if description := item.description: + item_data["description"] = description + try: + await self.hass.async_add_executor_job( + partial(self._calendar.save_todo, **item_data), + ) + 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["SUMMARY"] = item.summary or "" + if status := item.status: + vtodo["STATUS"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + todo.set_due(due) # type: ignore[attr-defined] + else: + vtodo.pop("DUE", None) + if description := item.description: + vtodo["DESCRIPTION"] = description + else: + vtodo.pop("DESCRIPTION", None) + 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 2be0bd9a04b713..41e13b798b68dd 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -37,6 +37,7 @@ 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 @@ def _validate_rrule(value: Any) -> str: 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( @@ -301,11 +304,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: required_features=[CalendarEntityFeature.CREATE_EVENT], ) component.async_register_legacy_entity_service( - SERVICE_LIST_EVENTS, - SERVICE_LIST_EVENTS_SCHEMA, + 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 @@ -420,7 +429,7 @@ def _api_event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, Any]: def _list_events_dict_factory( - obj: Iterable[tuple[str, Any]] + obj: Iterable[tuple[str, Any]], ) -> dict[str, JsonValueType]: """Convert CalendarEvent dataclass items to dictionary of attributes.""" return { @@ -809,7 +818,7 @@ async def handle_calendar_event_update( def _validate_timespan( - values: dict[str, Any] + values: dict[str, Any], ) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]: """Parse a create event service call and convert the args ofr a create event entity call. @@ -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 712d6ad88232e7..2e926fbdeed3a8 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 20679ed09b2e5e..78b8407240c746 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/camera/__init__.py b/homeassistant/components/camera/__init__.py index bb5a44a530c6f0..7a56292f7bb720 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -5,14 +5,15 @@ import collections from collections.abc import Awaitable, Callable, Iterable from contextlib import suppress -from dataclasses import asdict, dataclass +from dataclasses import asdict from datetime import datetime, timedelta from enum import IntFlag from functools import partial import logging import os from random import SystemRandom -from typing import Any, Final, cast, final +import time +from typing import TYPE_CHECKING, Any, Final, cast, final from aiohttp import hdrs, web import attr @@ -51,6 +52,11 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval @@ -60,6 +66,8 @@ from homeassistant.loader import bind_hass from .const import ( # noqa: F401 + _DEPRECATED_STREAM_TYPE_HLS, + _DEPRECATED_STREAM_TYPE_WEB_RTC, CAMERA_IMAGE_TIMEOUT, CAMERA_STREAM_SOURCE_TIMEOUT, CONF_DURATION, @@ -70,13 +78,16 @@ PREF_ORIENTATION, PREF_PRELOAD_STREAM, SERVICE_RECORD, - STREAM_TYPE_HLS, - STREAM_TYPE_WEB_RTC, StreamType, ) from .img_util import scale_jpeg_camera_image from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401 +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) SERVICE_ENABLE_MOTION: Final = "enable_motion_detection" @@ -105,8 +116,16 @@ class CameraEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Pleease use the CameraEntityFeature enum instead. -SUPPORT_ON_OFF: Final = 1 -SUPPORT_STREAM: Final = 2 +_DEPRECATED_SUPPORT_ON_OFF: Final = DeprecatedConstantEnum( + CameraEntityFeature.ON_OFF, "2025.1" +) +_DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum( + CameraEntityFeature.STREAM, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"} @@ -132,8 +151,7 @@ class CameraEntityFeature(IntFlag): } -@dataclass -class CameraEntityDescription(EntityDescription): +class CameraEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes camera entities.""" @@ -216,7 +234,7 @@ async def _async_get_stream_image( height: int | None = None, wait_for_next_keyframe: bool = False, ) -> bytes | None: - if not camera.stream and camera.supported_features & SUPPORT_STREAM: + if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features: camera.stream = await camera.async_create_stream() if camera.stream: return await camera.stream.async_get_image( @@ -277,6 +295,7 @@ async def write_to_mjpeg_stream(img_bytes: bytes) -> None: last_image = None while True: + last_fetch = time.monotonic() img_bytes = await image_cb() if not img_bytes: break @@ -290,7 +309,11 @@ async def write_to_mjpeg_stream(img_bytes: bytes) -> None: await write_to_mjpeg_stream(img_bytes) last_image = img_bytes - await asyncio.sleep(interval) + next_fetch = last_fetch + interval + now = time.monotonic() + if next_fetch > now: + sleep_time = next_fetch - now + await asyncio.sleep(sleep_time) return response @@ -394,7 +417,7 @@ async def preload_stream(_event: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, preload_stream) @callback - def update_tokens(time: datetime) -> None: + def update_tokens(t: datetime) -> None: """Update tokens of the entities.""" for entity in component.entities: entity.async_update_token() @@ -446,7 +469,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class Camera(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "brand", + "frame_interval", + "frontend_stream_type", + "is_on", + "is_recording", + "is_streaming", + "model", + "motion_detection_enabled", + "supported_features", +} + + +class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """The base class for camera entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -489,37 +525,50 @@ def use_stream_for_stills(self) -> bool: """Whether or not to use stream to generate stills.""" return False - @property + @cached_property def supported_features(self) -> CameraEntityFeature: """Flag supported features.""" return self._attr_supported_features @property + def supported_features_compat(self) -> CameraEntityFeature: + """Return the supported features as CameraEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = CameraEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + + @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" return self._attr_is_recording - @property + @cached_property def is_streaming(self) -> bool: """Return true if the device is streaming.""" return self._attr_is_streaming - @property + @cached_property def brand(self) -> str | None: """Return the camera brand.""" return self._attr_brand - @property + @cached_property def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return self._attr_motion_detection_enabled - @property + @cached_property def model(self) -> str | None: """Return the camera model.""" return self._attr_model - @property + @cached_property def frame_interval(self) -> float: """Return the interval between frames of the mjpeg stream.""" return self._attr_frame_interval @@ -534,7 +583,7 @@ def frontend_stream_type(self) -> StreamType | None: """ if hasattr(self, "_attr_frontend_stream_type"): return self._attr_frontend_stream_type - if not self.supported_features & CameraEntityFeature.STREAM: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return None if self._rtsp_to_webrtc: return StreamType.WEB_RTC @@ -637,7 +686,7 @@ def state(self) -> str: return STATE_STREAMING return STATE_IDLE - @property + @cached_property def is_on(self) -> bool: """Return true if on.""" return self._attr_is_on @@ -722,7 +771,7 @@ async def async_refresh_providers(self) -> None: async def _async_use_rtsp_to_webrtc(self) -> bool: """Determine if a WebRTC provider can be used for the camera.""" - if not self.supported_features & CameraEntityFeature.STREAM: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return False if DATA_RTSP_TO_WEB_RTC not in self.hass.data: return False diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index f745f60b51ace6..da41c0b9fabc72 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -1,7 +1,14 @@ """Constants for Camera component.""" from enum import StrEnum +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "camera" DATA_CAMERA_PREFS: Final = "camera_prefs" @@ -36,5 +43,10 @@ class StreamType(StrEnum): # These constants are deprecated as of Home Assistant 2022.5 # Please use the StreamType enum instead. -STREAM_TYPE_HLS = "hls" -STREAM_TYPE_WEB_RTC = "web_rtc" +_DEPRECATED_STREAM_TYPE_HLS = DeprecatedConstantEnum(StreamType.HLS, "2025.1") +_DEPRECATED_STREAM_TYPE_WEB_RTC = DeprecatedConstantEnum(StreamType.WEB_RTC, "2025.1") + + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/camera/significant_change.py b/homeassistant/components/camera/significant_change.py new file mode 100644 index 00000000000000..4fc175b0723541 --- /dev/null +++ b/homeassistant/components/camera/significant_change.py @@ -0,0 +1,22 @@ +"""Helper to test significant Camera state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + return None diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 7cf318f12a641a..5035b3c6620208 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==13.0.7"], + "requirements": ["PyChromecast==13.0.8"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/ccm15/__init__.py b/homeassistant/components/ccm15/__init__.py new file mode 100644 index 00000000000000..ae48394c732b17 --- /dev/null +++ b/homeassistant/components/ccm15/__init__.py @@ -0,0 +1,34 @@ +"""The Midea ccm15 AC Controller integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import CCM15Coordinator + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Midea ccm15 AC Controller from a config entry.""" + + coordinator = CCM15Coordinator( + hass, + entry.data[CONF_HOST], + entry.data[CONF_PORT], + ) + 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/ccm15/climate.py b/homeassistant/components/ccm15/climate.py new file mode 100644 index 00000000000000..30896d12299db0 --- /dev/null +++ b/homeassistant/components/ccm15/climate.py @@ -0,0 +1,160 @@ +"""Climate device for CCM15 coordinator.""" +import logging +from typing import Any + +from ccm15 import CCM15DeviceState + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRECISION_WHOLE, + SWING_OFF, + SWING_ON, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONST_CMD_FAN_MAP, CONST_CMD_STATE_MAP, DOMAIN +from .coordinator import CCM15Coordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up all climate.""" + coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + ac_data: CCM15DeviceState = coordinator.data + entities = [ + CCM15Climate(coordinator.get_host(), ac_index, coordinator) + for ac_index in ac_data.devices + ] + async_add_entities(entities) + + +class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): + """Climate device for CCM15 coordinator.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_target_temperature_step = PRECISION_WHOLE + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.AUTO, + ] + _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _attr_swing_modes = [SWING_OFF, SWING_ON] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + ) + _attr_name = None + + def __init__( + self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator + ) -> None: + """Create a climate device managed from a coordinator.""" + super().__init__(coordinator) + self._ac_index: int = ac_index + self._attr_unique_id = f"{ac_host}.{ac_index}" + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, f"{ac_host}.{ac_index}"), + }, + name=f"Midea {ac_index}", + manufacturer="Midea", + model="CCM15", + ) + + @property + def data(self) -> CCM15DeviceState | None: + """Return device data.""" + return self.coordinator.get_ac_data(self._ac_index) + + @property + def current_temperature(self) -> int | None: + """Return current temperature.""" + if (data := self.data) is not None: + return data.temperature + return None + + @property + def target_temperature(self) -> int | None: + """Return target temperature.""" + if (data := self.data) is not None: + return data.temperature_setpoint + return None + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac mode.""" + if (data := self.data) is not None: + mode = data.ac_mode + return CONST_CMD_STATE_MAP[mode] + return None + + @property + def fan_mode(self) -> str | None: + """Return fan mode.""" + if (data := self.data) is not None: + mode = data.fan_mode + return CONST_CMD_FAN_MAP[mode] + return None + + @property + def swing_mode(self) -> str | None: + """Return swing mode.""" + if (data := self.data) is not None: + return SWING_ON if data.is_swing_on else SWING_OFF + return None + + @property + def available(self) -> bool: + """Return the avalability of the entity.""" + return self.data is not None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + if (data := self.data) is not None: + return {"error_code": data.error_code} + return {} + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: + await self.coordinator.async_set_temperature(self._ac_index, temperature) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the hvac mode.""" + await self.coordinator.async_set_hvac_mode(self._ac_index, hvac_mode) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + await self.coordinator.async_set_fan_mode(self._ac_index, fan_mode) + + async def async_turn_off(self) -> None: + """Turn off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_turn_on(self) -> None: + """Turn on.""" + await self.async_set_hvac_mode(HVACMode.AUTO) diff --git a/homeassistant/components/ccm15/config_flow.py b/homeassistant/components/ccm15/config_flow.py new file mode 100644 index 00000000000000..efde47b8d30d7d --- /dev/null +++ b/homeassistant/components/ccm15/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for Midea ccm15 AC Controller integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from ccm15 import CCM15Device +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_TIMEOUT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=80): cv.port, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Midea ccm15 AC Controller.""" + + 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: + self._async_abort_entries_match(user_input) + ccm15 = CCM15Device( + user_input[CONF_HOST], user_input[CONF_PORT], DEFAULT_TIMEOUT + ) + try: + if not await ccm15.async_test_connection(): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + return self.async_create_entry( + title=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/ccm15/const.py b/homeassistant/components/ccm15/const.py new file mode 100644 index 00000000000000..5e8d1b82bd86e7 --- /dev/null +++ b/homeassistant/components/ccm15/const.py @@ -0,0 +1,26 @@ +"""Constants for the Midea ccm15 AC Controller integration.""" + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVACMode, +) + +DOMAIN = "ccm15" +DEFAULT_TIMEOUT = 10 +DEFAULT_INTERVAL = 30 + +CONST_STATE_CMD_MAP = { + HVACMode.COOL: 0, + HVACMode.HEAT: 1, + HVACMode.DRY: 2, + HVACMode.FAN_ONLY: 3, + HVACMode.OFF: 4, + HVACMode.AUTO: 5, +} +CONST_CMD_STATE_MAP = {v: k for k, v in CONST_STATE_CMD_MAP.items()} +CONST_FAN_CMD_MAP = {FAN_AUTO: 0, FAN_LOW: 2, FAN_MEDIUM: 3, FAN_HIGH: 4, FAN_OFF: 5} +CONST_CMD_FAN_MAP = {v: k for k, v in CONST_FAN_CMD_MAP.items()} diff --git a/homeassistant/components/ccm15/coordinator.py b/homeassistant/components/ccm15/coordinator.py new file mode 100644 index 00000000000000..9d8a0281706504 --- /dev/null +++ b/homeassistant/components/ccm15/coordinator.py @@ -0,0 +1,76 @@ +"""Climate device for CCM15 coordinator.""" +import datetime +import logging + +from ccm15 import CCM15Device, CCM15DeviceState, CCM15SlaveDevice +import httpx + +from homeassistant.components.climate import HVACMode +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONST_FAN_CMD_MAP, + CONST_STATE_CMD_MAP, + DEFAULT_INTERVAL, + DEFAULT_TIMEOUT, +) + +_LOGGER = logging.getLogger(__name__) + + +class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]): + """Class to coordinate multiple CCM15Climate devices.""" + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=host, + update_interval=datetime.timedelta(seconds=DEFAULT_INTERVAL), + ) + self._ccm15 = CCM15Device(host, port, DEFAULT_TIMEOUT) + self._host = host + + def get_host(self) -> str: + """Get the host.""" + return self._host + + async def _async_update_data(self) -> CCM15DeviceState: + """Fetch data from Rain Bird device.""" + try: + return await self._fetch_data() + except httpx.RequestError as err: # pragma: no cover + raise UpdateFailed("Error communicating with Device") from err + + async def _fetch_data(self) -> CCM15DeviceState: + """Get the current status of all AC devices.""" + return await self._ccm15.get_status_async() + + async def async_set_state(self, ac_index: int, state: str, value: int) -> None: + """Set new target states.""" + if await self._ccm15.async_set_state(ac_index, state, value): + await self.async_request_refresh() + + def get_ac_data(self, ac_index: int) -> CCM15SlaveDevice | None: + """Get ac data from the ac_index.""" + if ac_index < 0 or ac_index >= len(self.data.devices): + # Network latency may return an empty or incomplete array + return None + return self.data.devices[ac_index] + + async def async_set_hvac_mode(self, ac_index, hvac_mode: HVACMode) -> None: + """Set the hvac mode.""" + _LOGGER.debug("Set Hvac[%s]='%s'", ac_index, str(hvac_mode)) + await self.async_set_state(ac_index, "mode", CONST_STATE_CMD_MAP[hvac_mode]) + + async def async_set_fan_mode(self, ac_index, fan_mode: str) -> None: + """Set the fan mode.""" + _LOGGER.debug("Set Fan[%s]='%s'", ac_index, fan_mode) + await self.async_set_state(ac_index, "fan", CONST_FAN_CMD_MAP[fan_mode]) + + async def async_set_temperature(self, ac_index, temp) -> None: + """Set the target temperature mode.""" + _LOGGER.debug("Set Temp[%s]='%s'", ac_index, temp) + await self.async_set_state(ac_index, "temp", temp) diff --git a/homeassistant/components/ccm15/diagnostics.py b/homeassistant/components/ccm15/diagnostics.py new file mode 100644 index 00000000000000..b4a3c80f319cd0 --- /dev/null +++ b/homeassistant/components/ccm15/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for CCM15.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import CCM15Coordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + return { + str(device_id): { + "is_celsius": device.is_celsius, + "locked_cool_temperature": device.locked_cool_temperature, + "locked_heat_temperature": device.locked_heat_temperature, + "locked_ac_mode": device.locked_ac_mode, + "error_code": device.error_code, + "ac_mode": device.ac_mode, + "fan_mode": device.fan_mode, + "is_ac_mode_locked": device.is_ac_mode_locked, + "temperature_setpoint": device.temperature_setpoint, + "fan_locked": device.fan_locked, + "is_remote_locked": device.is_remote_locked, + "temperature": device.temperature, + } + for device_id, device in coordinator.data.devices.items() + } diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json new file mode 100644 index 00000000000000..2d985d6148aaac --- /dev/null +++ b/homeassistant/components/ccm15/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ccm15", + "name": "Midea ccm15 AC Controller", + "codeowners": ["@ocalvo"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ccm15", + "iot_class": "local_polling", + "requirements": ["py-ccm15==0.0.9"] +} diff --git a/homeassistant/components/ccm15/strings.json b/homeassistant/components/ccm15/strings.json new file mode 100644 index 00000000000000..1ac7a25e6f89b1 --- /dev/null +++ b/homeassistant/components/ccm15/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index a075467a313fc9..78cb92944cbf86 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1,11 +1,10 @@ """Provides functionality to interact with climate devices.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import functools as ft import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, Literal, final import voluptuous as vol @@ -20,13 +19,18 @@ STATE_ON, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp @@ -34,6 +38,20 @@ from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( # noqa: F401 + _DEPRECATED_HVAC_MODE_AUTO, + _DEPRECATED_HVAC_MODE_COOL, + _DEPRECATED_HVAC_MODE_DRY, + _DEPRECATED_HVAC_MODE_FAN_ONLY, + _DEPRECATED_HVAC_MODE_HEAT, + _DEPRECATED_HVAC_MODE_HEAT_COOL, + _DEPRECATED_HVAC_MODE_OFF, + _DEPRECATED_SUPPORT_AUX_HEAT, + _DEPRECATED_SUPPORT_FAN_MODE, + _DEPRECATED_SUPPORT_PRESET_MODE, + _DEPRECATED_SUPPORT_SWING_MODE, + _DEPRECATED_SUPPORT_TARGET_HUMIDITY, + _DEPRECATED_SUPPORT_TARGET_TEMPERATURE, + _DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE, ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, @@ -65,10 +83,6 @@ FAN_OFF, FAN_ON, FAN_TOP, - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, HVAC_MODES, PRESET_ACTIVITY, PRESET_AWAY, @@ -85,13 +99,6 @@ SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, - SUPPORT_AUX_HEAT, - SUPPORT_FAN_MODE, - SUPPORT_PRESET_MODE, - SUPPORT_SWING_MODE, - SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -102,6 +109,11 @@ HVACMode, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + DEFAULT_MIN_TEMP = 7 DEFAULT_MAX_TEMP = 35 DEFAULT_MIN_HUMIDITY = 30 @@ -129,6 +141,12 @@ ), ) +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) + # mypy: disallow-any-generics @@ -149,7 +167,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", [ClimateEntityFeature.PRESET_MODE], ) component.async_register_entity_service( @@ -176,13 +194,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_FAN_MODE, {vol.Required(ATTR_FAN_MODE): cv.string}, - "async_set_fan_mode", + "async_handle_set_fan_mode_service", [ClimateEntityFeature.FAN_MODE], ) component.async_register_entity_service( SERVICE_SET_SWING_MODE, {vol.Required(ATTR_SWING_MODE): cv.string}, - "async_set_swing_mode", + "async_handle_set_swing_mode_service", [ClimateEntityFeature.SWING_MODE], ) @@ -201,12 +219,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class ClimateEntityDescription(EntityDescription): +class ClimateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes climate entities.""" -class ClimateEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "temperature_unit", + "current_humidity", + "target_humidity", + "hvac_mode", + "hvac_modes", + "hvac_action", + "current_temperature", + "target_temperature", + "target_temperature_step", + "target_temperature_high", + "target_temperature_low", + "preset_mode", + "preset_modes", + "is_aux_heat", + "fan_mode", + "fan_modes", + "swing_mode", + "swing_modes", + "supported_features", + "min_temp", + "max_temp", + "min_humidity", + "max_humidity", +} + + +class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for climate entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -273,7 +317,7 @@ def precision(self) -> float: @property def capability_attributes(self) -> dict[str, Any] | None: """Return the capability attributes.""" - supported_features = self.supported_features + supported_features = self.supported_features_compat temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -287,17 +331,17 @@ def capability_attributes(self) -> dict[str, Any] | None: if target_temperature_step := self.target_temperature_step: data[ATTR_TARGET_TEMP_STEP] = target_temperature_step - if supported_features & ClimateEntityFeature.TARGET_HUMIDITY: + if ClimateEntityFeature.TARGET_HUMIDITY in supported_features: data[ATTR_MIN_HUMIDITY] = self.min_humidity data[ATTR_MAX_HUMIDITY] = self.max_humidity - if supported_features & ClimateEntityFeature.FAN_MODE: + if ClimateEntityFeature.FAN_MODE in supported_features: data[ATTR_FAN_MODES] = self.fan_modes - if supported_features & ClimateEntityFeature.PRESET_MODE: + if ClimateEntityFeature.PRESET_MODE in supported_features: data[ATTR_PRESET_MODES] = self.preset_modes - if supported_features & ClimateEntityFeature.SWING_MODE: + if ClimateEntityFeature.SWING_MODE in supported_features: data[ATTR_SWING_MODES] = self.swing_modes return data @@ -306,7 +350,7 @@ def capability_attributes(self) -> dict[str, Any] | None: @property def state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" - supported_features = self.supported_features + supported_features = self.supported_features_compat temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -317,7 +361,7 @@ def state_attributes(self) -> dict[str, Any]: ), } - if supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: + if ClimateEntityFeature.TARGET_TEMPERATURE in supported_features: data[ATTR_TEMPERATURE] = show_temp( hass, self.target_temperature, @@ -325,7 +369,7 @@ def state_attributes(self) -> dict[str, Any]: precision, ) - if supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: + if ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in supported_features: data[ATTR_TARGET_TEMP_HIGH] = show_temp( hass, self.target_temperature_high, temperature_unit, precision ) @@ -336,72 +380,72 @@ def state_attributes(self) -> dict[str, Any]: if (current_humidity := self.current_humidity) is not None: data[ATTR_CURRENT_HUMIDITY] = current_humidity - if supported_features & ClimateEntityFeature.TARGET_HUMIDITY: + if ClimateEntityFeature.TARGET_HUMIDITY in supported_features: data[ATTR_HUMIDITY] = self.target_humidity - if supported_features & ClimateEntityFeature.FAN_MODE: + if ClimateEntityFeature.FAN_MODE in supported_features: data[ATTR_FAN_MODE] = self.fan_mode if hvac_action := self.hvac_action: data[ATTR_HVAC_ACTION] = hvac_action - if supported_features & ClimateEntityFeature.PRESET_MODE: + if ClimateEntityFeature.PRESET_MODE in supported_features: data[ATTR_PRESET_MODE] = self.preset_mode - if supported_features & ClimateEntityFeature.SWING_MODE: + if ClimateEntityFeature.SWING_MODE in supported_features: data[ATTR_SWING_MODE] = self.swing_mode - if supported_features & ClimateEntityFeature.AUX_HEAT: + if ClimateEntityFeature.AUX_HEAT in supported_features: data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF return data - @property + @cached_property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" return self._attr_temperature_unit - @property + @cached_property def current_humidity(self) -> int | None: """Return the current humidity.""" return self._attr_current_humidity - @property + @cached_property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" return self._attr_target_humidity - @property + @cached_property def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" return self._attr_hvac_mode - @property + @cached_property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" return self._attr_hvac_modes - @property + @cached_property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" return self._attr_hvac_action - @property + @cached_property def current_temperature(self) -> float | None: """Return the current temperature.""" return self._attr_current_temperature - @property + @cached_property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._attr_target_temperature - @property + @cached_property def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" return self._attr_target_temperature_step - @property + @cached_property def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach. @@ -409,7 +453,7 @@ def target_temperature_high(self) -> float | None: """ return self._attr_target_temperature_high - @property + @cached_property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach. @@ -417,7 +461,7 @@ def target_temperature_low(self) -> float | None: """ return self._attr_target_temperature_low - @property + @cached_property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp. @@ -425,7 +469,7 @@ def preset_mode(self) -> str | None: """ return self._attr_preset_mode - @property + @cached_property def preset_modes(self) -> list[str] | None: """Return a list of available preset modes. @@ -433,7 +477,7 @@ def preset_modes(self) -> list[str] | None: """ return self._attr_preset_modes - @property + @cached_property def is_aux_heat(self) -> bool | None: """Return true if aux heater. @@ -441,7 +485,7 @@ def is_aux_heat(self) -> bool | None: """ return self._attr_is_aux_heat - @property + @cached_property def fan_mode(self) -> str | None: """Return the fan setting. @@ -449,7 +493,7 @@ def fan_mode(self) -> str | None: """ return self._attr_fan_mode - @property + @cached_property def fan_modes(self) -> list[str] | None: """Return the list of available fan modes. @@ -457,7 +501,7 @@ def fan_modes(self) -> list[str] | None: """ return self._attr_fan_modes - @property + @cached_property def swing_mode(self) -> str | None: """Return the swing setting. @@ -465,7 +509,7 @@ def swing_mode(self) -> str | None: """ return self._attr_swing_mode - @property + @cached_property def swing_modes(self) -> list[str] | None: """Return the list of available swing modes. @@ -473,6 +517,35 @@ def swing_modes(self) -> list[str] | None: """ return self._attr_swing_modes + @final + @callback + def _valid_mode_or_raise( + self, + mode_type: Literal["preset", "swing", "fan"], + mode: str, + modes: list[str] | None, + ) -> None: + """Raise ServiceValidationError on invalid modes.""" + if modes and mode in modes: + return + modes_str: str = ", ".join(modes) if modes else "" + if mode_type == "preset": + translation_key = "not_valid_preset_mode" + elif mode_type == "swing": + translation_key = "not_valid_swing_mode" + elif mode_type == "fan": + translation_key = "not_valid_fan_mode" + raise ServiceValidationError( + f"The {mode_type}_mode {mode} is not a valid {mode_type}_mode:" + f" {modes_str}", + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={ + "mode": mode, + "modes": modes_str, + }, + ) + def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" raise NotImplementedError() @@ -491,6 +564,12 @@ async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" await self.hass.async_add_executor_job(self.set_humidity, humidity) + @final + async def async_handle_set_fan_mode_service(self, fan_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_mode_or_raise("fan", fan_mode, self.fan_modes) + await self.async_set_fan_mode(fan_mode) + def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" raise NotImplementedError() @@ -507,6 +586,12 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" await self.hass.async_add_executor_job(self.set_hvac_mode, hvac_mode) + @final + async def async_handle_set_swing_mode_service(self, swing_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_mode_or_raise("swing", swing_mode, self.swing_modes) + await self.async_set_swing_mode(swing_mode) + def set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" raise NotImplementedError() @@ -515,6 +600,12 @@ async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" await self.hass.async_add_executor_job(self.set_swing_mode, swing_mode) + @final + async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_mode_or_raise("preset", preset_mode, self.preset_modes) + await self.async_set_preset_mode(preset_mode) + def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" raise NotImplementedError() @@ -570,12 +661,25 @@ async def async_turn_off(self) -> None: if HVACMode.OFF in self.hvac_modes: await self.async_set_hvac_mode(HVACMode.OFF) - @property + @cached_property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" return self._attr_supported_features @property + def supported_features_compat(self) -> ClimateEntityFeature: + """Return the supported features as ClimateEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = ClimateEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + + @cached_property def min_temp(self) -> float: """Return the minimum temperature.""" if not hasattr(self, "_attr_min_temp"): @@ -584,7 +688,7 @@ def min_temp(self) -> float: ) return self._attr_min_temp - @property + @cached_property def max_temp(self) -> float: """Return the maximum temperature.""" if not hasattr(self, "_attr_max_temp"): @@ -593,12 +697,12 @@ def max_temp(self) -> float: ) return self._attr_max_temp - @property + @cached_property def min_humidity(self) -> int: """Return the minimum humidity.""" return self._attr_min_humidity - @property + @cached_property def max_humidity(self) -> int: """Return the maximum humidity.""" return self._attr_max_humidity diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 23c76c151d76bc..615dc7d48dde5f 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -1,6 +1,13 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial + +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) class HVACMode(StrEnum): @@ -31,13 +38,13 @@ class HVACMode(StrEnum): # These HVAC_MODE_* constants are deprecated as of Home Assistant 2022.5. # Please use the HVACMode enum instead. -HVAC_MODE_OFF = "off" -HVAC_MODE_HEAT = "heat" -HVAC_MODE_COOL = "cool" -HVAC_MODE_HEAT_COOL = "heat_cool" -HVAC_MODE_AUTO = "auto" -HVAC_MODE_DRY = "dry" -HVAC_MODE_FAN_ONLY = "fan_only" +_DEPRECATED_HVAC_MODE_OFF = DeprecatedConstantEnum(HVACMode.OFF, "2025.1") +_DEPRECATED_HVAC_MODE_HEAT = DeprecatedConstantEnum(HVACMode.HEAT, "2025.1") +_DEPRECATED_HVAC_MODE_COOL = DeprecatedConstantEnum(HVACMode.COOL, "2025.1") +_DEPRECATED_HVAC_MODE_HEAT_COOL = DeprecatedConstantEnum(HVACMode.HEAT_COOL, "2025.1") +_DEPRECATED_HVAC_MODE_AUTO = DeprecatedConstantEnum(HVACMode.AUTO, "2025.1") +_DEPRECATED_HVAC_MODE_DRY = DeprecatedConstantEnum(HVACMode.DRY, "2025.1") +_DEPRECATED_HVAC_MODE_FAN_ONLY = DeprecatedConstantEnum(HVACMode.FAN_ONLY, "2025.1") HVAC_MODES = [cls.value for cls in HVACMode] # No preset is active @@ -99,12 +106,12 @@ class HVACAction(StrEnum): # These CURRENT_HVAC_* constants are deprecated as of Home Assistant 2022.5. # Please use the HVACAction enum instead. -CURRENT_HVAC_OFF = "off" -CURRENT_HVAC_HEAT = "heating" -CURRENT_HVAC_COOL = "cooling" -CURRENT_HVAC_DRY = "drying" -CURRENT_HVAC_IDLE = "idle" -CURRENT_HVAC_FAN = "fan" +_DEPRECATED_CURRENT_HVAC_OFF = DeprecatedConstantEnum(HVACAction.OFF, "2025.1") +_DEPRECATED_CURRENT_HVAC_HEAT = DeprecatedConstantEnum(HVACAction.HEATING, "2025.1") +_DEPRECATED_CURRENT_HVAC_COOL = DeprecatedConstantEnum(HVACAction.COOLING, "2025.1") +_DEPRECATED_CURRENT_HVAC_DRY = DeprecatedConstantEnum(HVACAction.DRYING, "2025.1") +_DEPRECATED_CURRENT_HVAC_IDLE = DeprecatedConstantEnum(HVACAction.IDLE, "2025.1") +_DEPRECATED_CURRENT_HVAC_FAN = DeprecatedConstantEnum(HVACAction.FAN, "2025.1") CURRENT_HVAC_ACTIONS = [cls.value for cls in HVACAction] @@ -159,10 +166,28 @@ class ClimateEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the ClimateEntityFeature enum instead. -SUPPORT_TARGET_TEMPERATURE = 1 -SUPPORT_TARGET_TEMPERATURE_RANGE = 2 -SUPPORT_TARGET_HUMIDITY = 4 -SUPPORT_FAN_MODE = 8 -SUPPORT_PRESET_MODE = 16 -SUPPORT_SWING_MODE = 32 -SUPPORT_AUX_HEAT = 64 +_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum( + ClimateEntityFeature.TARGET_TEMPERATURE, "2025.1" +) +_DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE = DeprecatedConstantEnum( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, "2025.1" +) +_DEPRECATED_SUPPORT_TARGET_HUMIDITY = DeprecatedConstantEnum( + ClimateEntityFeature.TARGET_HUMIDITY, "2025.1" +) +_DEPRECATED_SUPPORT_FAN_MODE = DeprecatedConstantEnum( + ClimateEntityFeature.FAN_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum( + ClimateEntityFeature.PRESET_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_SWING_MODE = DeprecatedConstantEnum( + ClimateEntityFeature.SWING_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_AUX_HEAT = DeprecatedConstantEnum( + ClimateEntityFeature.AUX_HEAT, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 6714e0bf35a294..a920884c2524cc 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -72,7 +72,7 @@ async def async_get_actions( } actions.append({**base_action, CONF_TYPE: "set_hvac_mode"}) - if supported_features & const.SUPPORT_PRESET_MODE: + if supported_features & const.ClimateEntityFeature.PRESET_MODE: actions.append({**base_action, CONF_TYPE: "set_preset_mode"}) return actions diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index 57b9654651bcc8..78f358db32e41d 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -71,7 +71,7 @@ async def async_get_conditions( conditions.append({**base_condition, CONF_TYPE: "is_hvac_mode"}) - if supported_features & const.SUPPORT_PRESET_MODE: + if supported_features & const.ClimateEntityFeature.PRESET_MODE: conditions.append({**base_condition, CONF_TYPE: "is_preset_mode"}) return conditions diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py new file mode 100644 index 00000000000000..4152fb5ee2d50f --- /dev/null +++ b/homeassistant/components/climate/intent.py @@ -0,0 +1,82 @@ +"""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, vol.Optional("name"): 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) + elif "name" in slots: + # Filter by name + entity_name = slots["name"]["value"] + + for maybe_climate in intent.async_match_states( + hass, name=entity_name, domains=[DOMAIN] + ): + climate_state = maybe_climate + break + + if climate_state is None: + raise intent.IntentHandleError(f"No climate entity named {entity_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/climate/significant_change.py b/homeassistant/components/climate/significant_change.py new file mode 100644 index 00000000000000..7198153f9af920 --- /dev/null +++ b/homeassistant/components/climate/significant_change.py @@ -0,0 +1,106 @@ +"""Helper to test significant Climate state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ( + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + ha_unit = hass.config.units.temperature_unit + + for attr_name in changed_attrs: + if attr_name in [ + ATTR_AUX_HEAT, + ATTR_FAN_MODE, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ]: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + absolute_change: float | None = None + if attr_name in [ + ATTR_CURRENT_TEMPERATURE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + ]: + if ha_unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if attr_name in [ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY]: + absolute_change = 1.0 + + if absolute_change and check_absolute_change( + old_attr_value, new_attr_value, absolute_change + ): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 55ccef2bc76254..ef87f287430897 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -233,5 +233,16 @@ "heat": "Heat" } } + }, + "exceptions": { + "not_valid_preset_mode": { + "message": "Preset mode {mode} is not valid. Valid preset modes are: {modes}." + }, + "not_valid_swing_mode": { + "message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}." + }, + "not_valid_fan_mode": { + "message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}." + } } } diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 4dc242376d9d9e..d7d57835e3ade1 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import alexa, google_assistant +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DESCRIPTION, CONF_MODE, @@ -51,6 +52,7 @@ CONF_SERVICEHANDLERS_SERVER, CONF_THINGTALK_SERVER, CONF_USER_POOL_ID, + DATA_PLATFORMS_SETUP, DOMAIN, MODE_DEV, MODE_PROD, @@ -61,6 +63,8 @@ DEFAULT_MODE = MODE_PROD +PLATFORMS = [Platform.BINARY_SENSOR, Platform.STT] + SERVICE_REMOTE_CONNECT = "remote_connect" SERVICE_REMOTE_DISCONNECT = "remote_disconnect" @@ -262,6 +266,12 @@ async def async_startup_repairs(_: datetime) -> None: async_manage_legacy_subscription_issue(hass, subscription_info) loaded = False + stt_platform_loaded = asyncio.Event() + tts_platform_loaded = asyncio.Event() + hass.data[DATA_PLATFORMS_SETUP] = { + Platform.STT: stt_platform_loaded, + Platform.TTS: tts_platform_loaded, + } async def _on_start() -> None: """Discover platforms.""" @@ -272,15 +282,15 @@ async def _on_start() -> None: return loaded = True - stt_platform_loaded = asyncio.Event() - tts_platform_loaded = asyncio.Event() - stt_info = {"platform_loaded": stt_platform_loaded} tts_info = {"platform_loaded": tts_platform_loaded} - await async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) - await async_load_platform(hass, Platform.STT, DOMAIN, stt_info, config) await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) - await asyncio.gather(stt_platform_loaded.wait(), tts_platform_loaded.wait()) + await tts_platform_loaded.wait() + + # The config entry should be loaded after the legacy tts platform is loaded + # to make sure that the tts integration is setup before we try to migrate + # old assist pipelines in the cloud stt entity. + await hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) async def _on_connect() -> None: """Handle cloud connect.""" @@ -304,7 +314,7 @@ async def _on_initialized() -> None: cloud.register_on_initialized(_on_initialized) await cloud.initialize() - await http_api.async_setup(hass) + http_api.async_setup(hass) account_link.async_setup(hass) @@ -340,3 +350,19 @@ async def remote_prefs_updated(prefs: CloudPreferences) -> None: await cloud.remote.disconnect() cloud.client.prefs.async_listen_updates(remote_prefs_updated) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + stt_platform_loaded: asyncio.Event = hass.data[DATA_PLATFORMS_SETUP][Platform.STT] + stt_platform_loaded.set() + + 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) + + return unload_ok diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py new file mode 100644 index 00000000000000..31e990cdb81170 --- /dev/null +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -0,0 +1,85 @@ +"""Handle Cloud assist pipelines.""" +import asyncio + +from homeassistant.components.assist_pipeline import ( + async_create_default_pipeline, + async_get_pipelines, + async_setup_pipeline_store, + async_update_pipeline, +) +from homeassistant.components.conversation import HOME_ASSISTANT_AGENT +from homeassistant.components.stt import DOMAIN as STT_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from .const import DATA_PLATFORMS_SETUP, DOMAIN, STT_ENTITY_UNIQUE_ID + + +async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: + """Create a cloud assist pipeline.""" + # Wait for stt and tts platforms to set up before creating the pipeline. + platforms_setup: dict[str, asyncio.Event] = hass.data[DATA_PLATFORMS_SETUP] + await asyncio.gather(*(event.wait() for event in platforms_setup.values())) + # Make sure the pipeline store is loaded, needed because assist_pipeline + # is an after dependency of cloud + await async_setup_pipeline_store(hass) + + entity_registry = er.async_get(hass) + new_stt_engine_id = entity_registry.async_get_entity_id( + STT_DOMAIN, DOMAIN, STT_ENTITY_UNIQUE_ID + ) + if new_stt_engine_id is None: + # If there's no cloud stt entity, we can't create a cloud pipeline. + return None + + def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: + """Return the ID of a cloud-enabled assist pipeline or None. + + Check if a cloud pipeline already exists with either + legacy or current cloud engine ids. + """ + for pipeline in async_get_pipelines(hass): + if ( + pipeline.conversation_engine == HOME_ASSISTANT_AGENT + and pipeline.stt_engine in (DOMAIN, new_stt_engine_id) + and pipeline.tts_engine == DOMAIN + ): + return pipeline.id + return None + + if (cloud_assist_pipeline(hass)) is not None or ( + cloud_pipeline := await async_create_default_pipeline( + hass, + stt_engine_id=new_stt_engine_id, + tts_engine_id=DOMAIN, + pipeline_name="Home Assistant Cloud", + ) + ) is None: + return None + + return cloud_pipeline.id + + +async def async_migrate_cloud_pipeline_stt_engine( + hass: HomeAssistant, stt_engine_id: str +) -> None: + """Migrate the speech-to-text engine in the cloud assist pipeline.""" + # Migrate existing pipelines with cloud stt to use new cloud stt engine id. + # Added in 2024.01.0. Can be removed in 2025.01.0. + + # We need to make sure that tts is loaded before this migration. + # Assist pipeline will call default engine of tts when setting up the store. + # Wait for the tts platform loaded event here. + platforms_setup: dict[str, asyncio.Event] = hass.data[DATA_PLATFORMS_SETUP] + await platforms_setup[Platform.TTS].wait() + + # Make sure the pipeline store is loaded, needed because assist_pipeline + # is an after dependency of cloud + await async_setup_pipeline_store(hass) + + pipelines = async_get_pipelines(hass) + for pipeline in pipelines: + if pipeline.stt_engine != DOMAIN: + continue + await async_update_pipeline(hass, pipeline, stt_engine=stt_engine_id) diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index e09122ac7bf59b..d56896dd7b1851 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections.abc import Callable from typing import Any from hass_nabucasa import Cloud @@ -11,11 +10,11 @@ BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .client import CloudClient from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN @@ -23,17 +22,13 @@ WAIT_UNTIL_CHANGE = 3 -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the cloud binary sensors.""" - if discovery_info is None: - return - cloud = hass.data[DOMAIN] - + """Set up the Home Assistant Cloud binary sensors.""" + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async_add_entities([CloudRemoteBinary(cloud)]) @@ -49,7 +44,6 @@ class CloudRemoteBinary(BinarySensorEntity): def __init__(self, cloud: Cloud[CloudClient]) -> None: """Initialize the binary sensor.""" self.cloud = cloud - self._unsub_dispatcher: Callable[[], None] | None = None @property def is_on(self) -> bool: @@ -69,12 +63,8 @@ async def async_state_update(data: Any) -> None: await asyncio.sleep(WAIT_UNTIL_CHANGE) self.async_write_ha_state() - self._unsub_dispatcher = async_dispatcher_connect( - self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update + ) ) - - async def async_will_remove_from_hass(self) -> None: - """Register update dispatcher.""" - if self._unsub_dispatcher is not None: - self._unsub_dispatcher() - self._unsub_dispatcher = None diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 019936869a1025..cef3c5f0d426bf 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -6,7 +6,7 @@ from http import HTTPStatus import logging from pathlib import Path -from typing import Any +from typing import Any, Literal import aiohttp from hass_nabucasa.client import CloudClient as Interface @@ -22,12 +22,18 @@ from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util.aiohttp import MockRequest, serialize_response from . import alexa_config, google_config from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN from .prefs import CloudPreferences +VALID_REPAIR_TRANSLATION_KEYS = { + "warn_bad_custom_domain_configuration", + "reset_bad_custom_domain_configuration", +} + class CloudClient(Interface): """Interface class for Home Assistant Cloud.""" @@ -302,3 +308,24 @@ async def async_cloudhooks_update( ) -> None: """Update local list of cloudhooks.""" await self._prefs.async_update(cloudhooks=data) + + async def async_create_repair_issue( + self, + identifier: str, + translation_key: str, + *, + placeholders: dict[str, str] | None = None, + severity: Literal["error", "warning"] = "warning", + ) -> None: + """Create a repair issue.""" + if translation_key not in VALID_REPAIR_TRANSLATION_KEYS: + raise ValueError(f"Invalid translation key {translation_key}") + async_create_issue( + hass=self._hass, + domain=DOMAIN, + issue_id=identifier, + translation_key=translation_key, + translation_placeholders=placeholders, + severity=IssueSeverity(severity), + is_fixable=False, + ) diff --git a/homeassistant/components/cloud/config_flow.py b/homeassistant/components/cloud/config_flow.py new file mode 100644 index 00000000000000..a9554d97294cf4 --- /dev/null +++ b/homeassistant/components/cloud/config_flow.py @@ -0,0 +1,23 @@ +"""Config flow for the Cloud integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class CloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for the Cloud integration.""" + + VERSION = 1 + + async def async_step_system( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the system step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + return self.async_create_entry(title="Home Assistant Cloud", data={}) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 6e20978ec8d200..db964607923880 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,5 +1,6 @@ """Constants for the cloud component.""" DOMAIN = "cloud" +DATA_PLATFORMS_SETUP = "cloud_platforms_setup" REQUEST_TIMEOUT = 10 PREF_ENABLE_ALEXA = "alexa_enabled" @@ -64,3 +65,5 @@ MODE_PROD = "production" DISPATCHER_REMOTE_UPDATE = "cloud_remote_update" + +STT_ENTITY_UNIQUE_ID = "cloud-speech-to-text" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index e3b1b39f687daf..849a1c99db996f 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,4 +1,6 @@ """The HTTP api to control the cloud integration.""" +from __future__ import annotations + import asyncio from collections.abc import Awaitable, Callable, Coroutine, Mapping from contextlib import suppress @@ -16,7 +18,7 @@ from hass_nabucasa.voice import MAP_VOICE import voluptuous as vol -from homeassistant.components import assist_pipeline, conversation, websocket_api +from homeassistant.components import websocket_api from homeassistant.components.alexa import ( entities as alexa_entities, errors as alexa_errors, @@ -26,12 +28,13 @@ from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info from .alexa_config import entity_supported as entity_supported_by_alexa +from .assist_pipeline import async_create_cloud_pipeline from .client import CloudClient from .const import ( DOMAIN, @@ -63,7 +66,8 @@ } -async def async_setup(hass: HomeAssistant) -> None: +@callback +def async_setup(hass: HomeAssistant) -> None: """Initialize the HTTP API.""" websocket_api.async_register_command(hass, websocket_cloud_status) websocket_api.async_register_command(hass, websocket_subscription) @@ -113,7 +117,9 @@ async def async_setup(hass: HomeAssistant) -> None: def _handle_cloud_errors( - handler: Callable[Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response]] + handler: Callable[ + Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response] + ], ) -> Callable[ Concatenate[_HassViewT, web.Request, _P], Coroutine[Any, Any, web.Response] ]: @@ -140,7 +146,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], @@ -210,31 +216,11 @@ class CloudLoginView(HomeAssistantView): ) async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle login request.""" - - def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: - """Return the ID of a cloud-enabled assist pipeline or None.""" - for pipeline in assist_pipeline.async_get_pipelines(hass): - if ( - pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT - and pipeline.stt_engine == DOMAIN - and pipeline.tts_engine == DOMAIN - ): - return pipeline.id - return None - - hass = request.app["hass"] - cloud = hass.data[DOMAIN] + hass: HomeAssistant = request.app["hass"] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] await cloud.login(data["email"], data["password"]) - # Make sure the pipeline store is loaded, needed because assist_pipeline - # is an after dependency of cloud - await assist_pipeline.async_setup_pipeline_store(hass) - new_cloud_pipeline_id: str | None = None - if (cloud_assist_pipeline(hass)) is None: - if cloud_pipeline := await assist_pipeline.async_create_default_pipeline( - hass, DOMAIN, DOMAIN - ): - new_cloud_pipeline_id = cloud_pipeline.id + new_cloud_pipeline_id = await async_create_cloud_pipeline(hass) return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id}) @@ -362,8 +348,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/manifest.json b/homeassistant/components/cloud/manifest.json index 6d5c954361bb5d..f7337e1d7714ad 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.74.0"] + "requirements": ["hass-nabucasa==0.75.1"] } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 9c1f29cfcafc78..56fb3c0f5c9894 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -1,4 +1,10 @@ { + "config": { + "step": {}, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, "system_health": { "info": { "can_reach_cert_server": "Reach Certificate Server", @@ -30,6 +36,14 @@ "operation_took_too_long": "The operation took too long. Please try again later." } } + }, + "warn_bad_custom_domain_configuration": { + "title": "Detected wrong custom domain configuration", + "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. Please check the DNS configuration of your domain and make sure it points to the correct CNAME." + }, + "reset_bad_custom_domain_configuration": { + "title": "Custom domain ignored", + "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. This domain has now been ignored and will not be used for Home Assistant Cloud. If you want to use this domain, please fix the DNS configuration and restart Home Assistant. If you do not need this anymore, you can remove it from the account page." } }, "services": { diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 7b6da8b74039e2..b652a36fa8a701 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -13,37 +13,38 @@ AudioCodecs, AudioFormats, AudioSampleRates, - Provider, SpeechMetadata, SpeechResult, SpeechResultState, + SpeechToTextEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .assist_pipeline import async_migrate_cloud_pipeline_stt_engine from .client import CloudClient -from .const import DOMAIN +from .const import DOMAIN, STT_ENTITY_UNIQUE_ID _LOGGER = logging.getLogger(__name__) -async def async_get_engine( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> CloudProvider: - """Set up Cloud speech component.""" + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Home Assistant Cloud speech platform via config entry.""" cloud: Cloud[CloudClient] = hass.data[DOMAIN] + async_add_entities([CloudProviderEntity(cloud)]) - cloud_provider = CloudProvider(cloud) - if discovery_info is not None: - discovery_info["platform_loaded"].set() - return cloud_provider - -class CloudProvider(Provider): +class CloudProviderEntity(SpeechToTextEntity): """NabuCasa speech API provider.""" + _attr_name = "Home Assistant Cloud" + _attr_unique_id = STT_ENTITY_UNIQUE_ID + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Home Assistant NabuCasa Speech to text.""" self.cloud = cloud @@ -78,6 +79,10 @@ def supported_channels(self) -> list[AudioChannels]: """Return a list of supported channels.""" return [AudioChannels.CHANNEL_MONO] + async def async_added_to_hass(self) -> None: + """Run when entity is about to be added to hass.""" + await async_migrate_cloud_pipeline_stt_engine(self.hass, self.entity_id) + async def async_process_audio_stream( self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py index 1901bfdc0e7eeb..d4c6775c6b9fb1 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -4,8 +4,8 @@ import asyncio from datetime import timedelta import logging +import socket -from aiohttp import ClientSession import pycfdns from homeassistant.config_entries import ConfigEntry @@ -51,7 +51,7 @@ async def update_records(now): """Set up recurring update.""" try: await _async_update_cloudflare( - session, client, dns_zone, entry.data[CONF_RECORDS] + hass, client, dns_zone, entry.data[CONF_RECORDS] ) except ( pycfdns.AuthenticationException, @@ -63,7 +63,7 @@ async def update_records_service(call: ServiceCall) -> None: """Set up service for manual trigger.""" try: await _async_update_cloudflare( - session, client, dns_zone, entry.data[CONF_RECORDS] + hass, client, dns_zone, entry.data[CONF_RECORDS] ) except ( pycfdns.AuthenticationException, @@ -92,7 +92,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_cloudflare( - session: ClientSession, + hass: HomeAssistant, client: pycfdns.Client, dns_zone: pycfdns.ZoneModel, target_records: list[str], @@ -102,6 +102,7 @@ async def _async_update_cloudflare( 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): diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 04ae811197bfb3..028d37a73c5fae 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 @@ 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 d41bd6e0f78b97..dfa1e25d7d8907 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -1,13 +1,23 @@ """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.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_COUNTRY_CODE, + 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, @@ -15,9 +25,8 @@ SelectSelectorMode, ) -from .const import CONF_COUNTRY_CODE, DOMAIN -from .coordinator import get_data -from .exceptions import APIRatelimitExceeded, InvalidAuth +from .const import DOMAIN +from .helpers import fetch_latest_carbon_intensity from .util import get_extra_name TYPE_USE_HOME = "use_home_location" @@ -30,6 +39,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 +121,52 @@ async def async_step_country( "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/const.py b/homeassistant/components/co2signal/const.py index 1e0cbfe0f11275..b025c655ce6cbc 100644 --- a/homeassistant/components/co2signal/const.py +++ b/homeassistant/components/co2signal/const.py @@ -2,5 +2,4 @@ DOMAIN = "co2signal" -CONF_COUNTRY_CODE = "country_code" ATTRIBUTION = "Data provided by Electricity Maps" diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 24d7bbd18af643..115c976b46545f 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 db08aa4eca6aaa..1c53f7c5b088d1 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 cc8ee709bde3d4..00000000000000 --- 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 00000000000000..f61fadaf88c20d --- /dev/null +++ b/homeassistant/components/co2signal/helpers.py @@ -0,0 +1,28 @@ +"""Helper functions for the CO2 Signal integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aioelectricitymaps import ElectricityMaps +from aioelectricitymaps.models import CarbonIntensityResponse + +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + + +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 a4d7c55d6da207..c2e70bdb21ecc4 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -1,11 +1,11 @@ { "domain": "co2signal", "name": "Electricity Maps", - "codeowners": ["@jpbede"], + "codeowners": ["@jpbede", "@VIKTORVAV99"], "config_flow": true, "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.6"] } diff --git a/homeassistant/components/co2signal/models.py b/homeassistant/components/co2signal/models.py deleted file mode 100644 index 758bb15c5f0851..00000000000000 --- 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 d00bdf70d3e83b..9f955e35ed8041 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 .const import ATTRIBUTION, DOMAIN from .coordinator import CO2SignalCoordinator -SCAN_INTERVAL = timedelta(minutes=3) - -@dataclass +@dataclass(frozen=True, 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 @@ class CO2SensorEntityDescription(SensorEntityDescription): 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 @@ def __init__( 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 @@ def __init__( 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 4564fdf14bebf1..89289dd816dbb2 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/co2signal/util.py b/homeassistant/components/co2signal/util.py index af0bec34904cf4..b588e0abef9444 100644 --- a/homeassistant/components/co2signal/util.py +++ b/homeassistant/components/co2signal/util.py @@ -2,16 +2,15 @@ from __future__ import annotations from collections.abc import Mapping +from typing import Any -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE -from .const import CONF_COUNTRY_CODE - -def get_extra_name(config: Mapping) -> str | None: +def get_extra_name(config: Mapping[str, Any]) -> str | None: """Return the extra name describing the location if not home.""" if CONF_COUNTRY_CODE in config: - return config[CONF_COUNTRY_CODE] + return config[CONF_COUNTRY_CODE] # type: ignore[no-any-return] if CONF_LATITUDE in config: return f"{round(config[CONF_LATITUDE], 2)}, {round(config[CONF_LONGITUDE], 2)}" diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 5dc60f535d7427..380532954112b7 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -17,6 +17,7 @@ from . import get_accounts from .const import ( API_ACCOUNT_CURRENCY, + API_ACCOUNT_CURRENCY_CODE, API_RATES, API_RESOURCE_TYPE, API_TYPE_VAULT, @@ -81,7 +82,7 @@ async def validate_options( accounts = await hass.async_add_executor_job(get_accounts, client) accounts_currencies = [ - account[API_ACCOUNT_CURRENCY] + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] for account in accounts if account[API_RESOURCE_TYPE] != API_TYPE_VAULT ] diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index c5fdec4d511b4c..3fc8158f9709f2 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -12,14 +12,16 @@ API_ACCOUNT_AMOUNT = "amount" API_ACCOUNT_BALANCE = "balance" API_ACCOUNT_CURRENCY = "currency" +API_ACCOUNT_CURRENCY_CODE = "code" API_ACCOUNT_ID = "id" -API_ACCOUNT_NATIVE_BALANCE = "native_balance" +API_ACCOUNT_NATIVE_BALANCE = "balance" API_ACCOUNT_NAME = "name" API_ACCOUNTS_DATA = "data" API_RATES = "rates" API_RESOURCE_PATH = "resource_path" API_RESOURCE_TYPE = "type" API_TYPE_VAULT = "vault" +API_USD = "USD" WALLETS = { "1INCH": "1INCH", diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 47fd3b9112983f..1442a626f74208 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -14,9 +14,9 @@ API_ACCOUNT_AMOUNT, API_ACCOUNT_BALANCE, API_ACCOUNT_CURRENCY, + API_ACCOUNT_CURRENCY_CODE, API_ACCOUNT_ID, API_ACCOUNT_NAME, - API_ACCOUNT_NATIVE_BALANCE, API_RATES, API_RESOURCE_TYPE, API_TYPE_VAULT, @@ -55,7 +55,7 @@ async def async_setup_entry( entities: list[SensorEntity] = [] provided_currencies: list[str] = [ - account[API_ACCOUNT_CURRENCY] + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] for account in instance.accounts if account[API_RESOURCE_TYPE] != API_TYPE_VAULT ] @@ -106,26 +106,28 @@ def __init__(self, coinbase_data: CoinbaseData, currency: str) -> None: self._currency = currency for account in coinbase_data.accounts: if ( - account[API_ACCOUNT_CURRENCY] != currency + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency or account[API_RESOURCE_TYPE] == API_TYPE_VAULT ): continue self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}" self._attr_unique_id = ( f"coinbase-{account[API_ACCOUNT_ID]}-wallet-" - f"{account[API_ACCOUNT_CURRENCY]}" + f"{account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]}" ) self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] - self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY] + self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][ + API_ACCOUNT_CURRENCY_CODE + ] self._attr_icon = CURRENCY_ICONS.get( - account[API_ACCOUNT_CURRENCY], DEFAULT_COIN_ICON + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE], + DEFAULT_COIN_ICON, + ) + self._native_balance = round( + float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]) + / float(coinbase_data.exchange_rates[API_RATES][currency]), + 2, ) - self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_AMOUNT - ] - self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_CURRENCY - ] break self._attr_state_class = SensorStateClass.TOTAL @@ -141,7 +143,7 @@ def __init__(self, coinbase_data: CoinbaseData, currency: str) -> None: def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the sensor.""" return { - ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._native_currency}", + ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}", } def update(self) -> None: @@ -149,17 +151,17 @@ def update(self) -> None: self._coinbase_data.update() for account in self._coinbase_data.accounts: if ( - account[API_ACCOUNT_CURRENCY] != self._currency + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] + != self._currency or account[API_RESOURCE_TYPE] == API_TYPE_VAULT ): continue self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] - self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_AMOUNT - ] - self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_CURRENCY - ] + self._native_balance = round( + float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]) + / float(self._coinbase_data.exchange_rates[API_RATES][self._currency]), + 2, + ) break diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index b271644234dd93..c51081196c975f 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -1,38 +1,67 @@ """Comelit integration.""" +from aiocomelit.const import BRIDGE + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from .const import DEFAULT_PORT, DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitBaseCoordinator, ComelitSerialBridge, ComelitVedoSystem -PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +BRIDGE_PLATFORMS = [ + Platform.COVER, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] +VEDO_PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Comelit platform.""" - coordinator = ComelitSerialBridge( - hass, - entry.data[CONF_HOST], - entry.data.get(CONF_PORT, DEFAULT_PORT), - entry.data[CONF_PIN], - ) + + coordinator: ComelitBaseCoordinator + if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: + coordinator = ComelitSerialBridge( + hass, + entry.data[CONF_HOST], + entry.data.get(CONF_PORT, DEFAULT_PORT), + entry.data[CONF_PIN], + ) + platforms = BRIDGE_PLATFORMS + else: + coordinator = ComelitVedoSystem( + hass, + entry.data[CONF_HOST], + entry.data.get(CONF_PORT, DEFAULT_PORT), + entry.data[CONF_PIN], + ) + platforms = VEDO_PLATFORMS 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) + 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): - coordinator: ComelitSerialBridge = hass.data[DOMAIN][entry.entry_id] + + if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: + platforms = BRIDGE_PLATFORMS + else: + platforms = VEDO_PLATFORMS + + coordinator: ComelitBaseCoordinator = hass.data[DOMAIN][entry.entry_id] + if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): await coordinator.api.logout() await coordinator.api.close() hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py new file mode 100644 index 00000000000000..33107dd3e82988 --- /dev/null +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -0,0 +1,153 @@ +"""Support for Comelit VEDO system.""" +from __future__ import annotations + +import logging + +from aiocomelit.api import ComelitVedoAreaObject +from aiocomelit.const import ALARM_AREAS, AlarmAreaState + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_DISARMING, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitVedoSystem + +_LOGGER = logging.getLogger(__name__) + +AWAY = "away" +DISABLE = "disable" +HOME = "home" +HOME_P1 = "home_p1" +HOME_P2 = "home_p2" +NIGHT = "night" + +ALARM_ACTIONS: dict[str, str] = { + DISABLE: "dis", # Disarm + HOME: "p1", # Arm P1 + NIGHT: "p12", # Arm P1+P2 + AWAY: "tot", # Arm P1+P2 + IR / volumetric +} + + +ALARM_AREA_ARMED_STATUS: dict[str, int] = { + HOME_P1: 1, + HOME_P2: 2, + NIGHT: 3, + AWAY: 4, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Comelit VEDO system alarm control panel devices.""" + + coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ComelitAlarmEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[ALARM_AREAS].values() + ) + + +class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanelEntity): + """Representation of a Ness alarm panel.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_code_format = CodeFormat.NUMBER + _attr_code_arm_required = False + _attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME + ) + + def __init__( + self, + coordinator: ComelitVedoSystem, + area: ComelitVedoAreaObject, + config_entry_entry_id: str, + ) -> None: + """Initialize the alarm panel.""" + self._api = coordinator.api + self._area_index = area.index + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{area.index}" + self._attr_device_info = coordinator.platform_device_info(area, "area") + if area.p2: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_NIGHT + + @property + def _area(self) -> ComelitVedoAreaObject: + """Return area object.""" + return self.coordinator.data[ALARM_AREAS][self._area_index] + + @property + def available(self) -> bool: + """Return True if alarm is available.""" + if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]: + return False + return super().available + + @property + def state(self) -> StateType: + """Return the state of the alarm.""" + + _LOGGER.debug( + "Area %s status is: %s. Armed is %s", + self._area.name, + self._area.human_status, + self._area.armed, + ) + if self._area.human_status == AlarmAreaState.ARMED: + if self._area.armed == ALARM_AREA_ARMED_STATUS[AWAY]: + return STATE_ALARM_ARMED_AWAY + if self._area.armed == ALARM_AREA_ARMED_STATUS[NIGHT]: + return STATE_ALARM_ARMED_NIGHT + return STATE_ALARM_ARMED_HOME + + return { + AlarmAreaState.DISARMED: STATE_ALARM_DISARMED, + AlarmAreaState.ENTRY_DELAY: STATE_ALARM_DISARMING, + AlarmAreaState.EXIT_DELAY: STATE_ALARM_ARMING, + AlarmAreaState.TRIGGERED: STATE_ALARM_TRIGGERED, + }.get(self._area.human_status) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + if code != str(self._api.device_pin): + return + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE]) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY]) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME]) + + async def async_alarm_arm_night(self, code: str | None = None) -> None: + """Send arm night command.""" + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT]) diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index b95853edf9dbd2..cbd79ac1e1a771 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -4,16 +4,22 @@ from collections.abc import Mapping from typing import Any -from aiocomelit import ComeliteSerialBridgeApi, exceptions as aiocomelit_exceptions +from aiocomelit import ( + ComeliteSerialBridgeApi, + ComelitVedoApi, + exceptions as aiocomelit_exceptions, +) +from aiocomelit.api import ComelitCommonApi +from aiocomelit.const import BRIDGE import voluptuous as vol from homeassistant import core, exceptions from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from .const import _LOGGER, DEFAULT_PORT, DOMAIN +from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN DEFAULT_HOST = "192.168.1.252" DEFAULT_PIN = 111111 @@ -27,6 +33,7 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), } ) @@ -39,7 +46,11 @@ async def validate_input( ) -> dict[str, str]: """Validate the user input allows us to connect.""" - api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + api: ComelitCommonApi + if data.get(CONF_TYPE, BRIDGE) == BRIDGE: + api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + else: + api = ComelitVedoApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) try: await api.login() diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index 57b7f35bc170da..ca10e0b0a7496d 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -1,7 +1,10 @@ """Comelit constants.""" import logging +from aiocomelit.const import BRIDGE, VEDO + _LOGGER = logging.getLogger(__package__) DOMAIN = "comelit" DEFAULT_PORT = 80 +DEVICE_TYPE_LIST = [BRIDGE, VEDO] diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index d3bc973429b9ac..6559e2ffb876ab 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -1,9 +1,18 @@ """Support for Comelit.""" +from abc import abstractmethod from datetime import timedelta from typing import Any -from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject, exceptions -from aiocomelit.const import BRIDGE +from aiocomelit import ( + ComeliteSerialBridgeApi, + ComelitSerialBridgeObject, + ComelitVedoApi, + ComelitVedoAreaObject, + ComelitVedoZoneObject, + exceptions, +) +from aiocomelit.api import ComelitCommonApi +from aiocomelit.const import BRIDGE, VEDO from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -14,19 +23,18 @@ from .const import _LOGGER, DOMAIN -class ComelitSerialBridge(DataUpdateCoordinator): - """Queries Comelit Serial Bridge.""" +class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Base coordinator for Comelit Devices.""" + _hw_version: str config_entry: ConfigEntry + api: ComelitCommonApi - def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + def __init__(self, hass: HomeAssistant, device: str, host: str) -> None: """Initialize the scanner.""" + self._device = device self._host = host - self._port = port - self._pin = pin - - self.api = ComeliteSerialBridgeApi(host, port, pin) super().__init__( hass=hass, @@ -38,38 +46,41 @@ def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, identifiers={(DOMAIN, self.config_entry.entry_id)}, - model=BRIDGE, - name=f"{BRIDGE} ({self.api.host})", - **self.basic_device_info, + model=device, + name=f"{device} ({self._host})", + manufacturer="Comelit", + hw_version=self._hw_version, ) - @property - def basic_device_info(self) -> dict: - """Set basic device info.""" - - return { - "manufacturer": "Comelit", - "hw_version": "20003101", - } - - def platform_device_info(self, device: ComelitSerialBridgeObject) -> dr.DeviceInfo: + def platform_device_info( + self, + object_class: ComelitVedoZoneObject + | ComelitVedoAreaObject + | ComelitSerialBridgeObject, + object_type: str, + ) -> dr.DeviceInfo: """Set platform device info.""" return dr.DeviceInfo( identifiers={ - (DOMAIN, f"{self.config_entry.entry_id}-{device.type}-{device.index}") + ( + DOMAIN, + f"{self.config_entry.entry_id}-{object_type}-{object_class.index}", + ) }, via_device=(DOMAIN, self.config_entry.entry_id), - name=device.name, - model=f"{BRIDGE} {device.type}", - **self.basic_device_info, + name=object_class.name, + model=f"{self._device} {object_type}", + manufacturer="Comelit", + hw_version=self._hw_version, ) async def _async_update_data(self) -> dict[str, Any]: """Update device data.""" - _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) + _LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host) try: await self.api.login() + return await self._async_update_system_data() except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) await self.api.close() @@ -77,4 +88,40 @@ async def _async_update_data(self) -> dict[str, Any]: except exceptions.CannotAuthenticate: raise ConfigEntryAuthFailed + return {} + + @abstractmethod + async def _async_update_system_data(self) -> dict[str, Any]: + """Class method for updating data.""" + + +class ComelitSerialBridge(ComelitBaseCoordinator): + """Queries Comelit Serial Bridge.""" + + _hw_version = "20003101" + api: ComeliteSerialBridgeApi + + def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + """Initialize the scanner.""" + self.api = ComeliteSerialBridgeApi(host, port, pin) + super().__init__(hass, BRIDGE, host) + + async def _async_update_system_data(self) -> dict[str, Any]: + """Specific method for updating data.""" return await self.api.get_all_devices() + + +class ComelitVedoSystem(ComelitBaseCoordinator): + """Queries Comelit VEDO system.""" + + _hw_version = "VEDO IP" + api: ComelitVedoApi + + def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + """Initialize the scanner.""" + self.api = ComelitVedoApi(host, port, pin) + super().__init__(hass, VEDO, host) + + async def _async_update_system_data(self) -> dict[str, Any]: + """Specific method for updating data.""" + return await self.api.get_all_areas_and_zones() diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 4a3c8eed63ceca..d35180c761bcf6 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -54,7 +54,7 @@ def __init__( # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) # Device doesn't provide a status so we assume UNKNOWN at first startup self._last_action: int | None = None self._last_state: str | None = None @@ -109,7 +109,7 @@ async def async_stop_cover(self, **_kwargs: Any) -> None: if not self.is_closing and not self.is_opening: return - action = STATE_OFF if self.is_closing else STATE_ON + action = STATE_ON if self.is_closing else STATE_OFF await self._api.set_device_status(COVER, self._device.index, action) @callback diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 95906f7ec6e5c5..7deb3d49624b6b 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -50,7 +50,7 @@ def __init__( # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) async def _light_set_state(self, state: int) -> None: """Set desired light state.""" diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 5978f17cfc473e..8b50ccdf767cd5 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.3.0"] + "requirements": ["aiocomelit==0.7.0"] } diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 554433fa6ad392..66b04e6ae98fc9 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -3,8 +3,8 @@ from typing import Final -from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import OTHER +from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject +from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,16 +12,16 @@ SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower +from homeassistant.const import CONF_TYPE, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitSerialBridge, ComelitVedoSystem -SENSOR_TYPES: Final = ( +SENSOR_BRIDGE_TYPES: Final = ( SensorEntityDescription( key="power", native_unit_of_measurement=UnitOfPower.WATT, @@ -29,6 +29,17 @@ ), ) +SENSOR_VEDO_TYPES: Final = ( + SensorEntityDescription( + key="human_status", + translation_key="zone_status", + name=None, + device_class=SensorDeviceClass.ENUM, + icon="mdi:shield-check", + options=[zone_state.value for zone_state in AlarmZoneState], + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -37,23 +48,57 @@ async def async_setup_entry( ) -> None: """Set up Comelit sensors.""" + if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: + await async_setup_bridge_entry(hass, config_entry, async_add_entities) + else: + await async_setup_vedo_entry(hass, config_entry, async_add_entities) + + +async def async_setup_bridge_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit Bridge sensors.""" + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] - entities: list[ComelitSensorEntity] = [] + entities: list[ComelitBridgeSensorEntity] = [] for device in coordinator.data[OTHER].values(): entities.extend( - ComelitSensorEntity(coordinator, device, config_entry.entry_id, sensor_desc) - for sensor_desc in SENSOR_TYPES + ComelitBridgeSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc + ) + for sensor_desc in SENSOR_BRIDGE_TYPES ) + async_add_entities(entities) + +async def async_setup_vedo_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit VEDO sensors.""" + + coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ComelitVedoSensorEntity] = [] + for device in coordinator.data[ALARM_ZONES].values(): + entities.extend( + ComelitVedoSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc + ) + for sensor_desc in SENSOR_VEDO_TYPES + ) async_add_entities(entities) -class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): +class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): """Sensor device.""" _attr_has_entity_name = True - entity_description: SensorEntityDescription + _attr_name = None def __init__( self, @@ -69,7 +114,7 @@ def __init__( # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) self.entity_description = description @@ -80,3 +125,45 @@ def native_value(self) -> StateType: self.coordinator.data[OTHER][self._device.index], self.entity_description.key, ) + + +class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity): + """Sensor device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ComelitVedoSystem, + zone: ComelitVedoZoneObject, + config_entry_entry_id: str, + description: SensorEntityDescription, + ) -> None: + """Init sensor entity.""" + self._api = coordinator.api + self._zone = zone + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{zone.index}" + self._attr_device_info = coordinator.platform_device_info(zone, "zone") + + self.entity_description = description + + @property + def _zone_object(self) -> ComelitVedoZoneObject: + """Zone object.""" + return self.coordinator.data[ALARM_ZONES][self._zone.index] + + @property + def available(self) -> bool: + """Sensor availability.""" + return self._zone_object.human_status != AlarmZoneState.UNAVAILABLE + + @property + def native_value(self) -> StateType: + """Sensor value.""" + if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN: + return None + + return status.value diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 730674e913a02d..dac8bc4123d31f 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." } } }, @@ -28,5 +31,22 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "zone_status": { + "state": { + "alarm": "Alarm", + "armed": "Armed", + "open": "Open", + "excluded": "Excluded", + "faulty": "Faulty", + "inhibited": "Inhibited", + "isolated": "Isolated", + "rest": "Rest", + "sabotated": "Sabotated" + } + } + } } } diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 379b936c3bb511..ce08c64fa78d3a 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -56,7 +56,7 @@ def __init__( # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) if device.type == OTHER: self._attr_device_class = SwitchDeviceClass.OUTLET diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 3f00a9b59f0cd8..f76ed5939f56ef 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -22,10 +22,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 21e6eda255deb9..421643f5ced1ba 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -79,14 +79,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ComfoconnectRequiredKeysMixin: """Mixin for required keys.""" sensor_id: int -@dataclass +@dataclass(frozen=True) class ComfoconnectSensorEntityDescription( SensorEntityDescription, ComfoconnectRequiredKeysMixin ): diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index e1a051cea33b10..ba4292b5a65a90 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -200,7 +200,7 @@ async def async_load_platforms( load_coroutines: list[Coroutine[Any, Any, None]] = [] platforms: list[Platform] = [] - reload_configs: list[tuple] = [] + reload_configs: list[tuple[Platform, dict[str, Any]]] = [] for platform_config in command_line_config: for platform, _config in platform_config.items(): if (mapped_platform := PLATFORM_MAPPING[platform]) not in platforms: diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index f559812207f699..31259ddf90910e 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from typing import cast from homeassistant.components.binary_sensor import ( @@ -115,7 +115,7 @@ def __init__( async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() - await self._update_entity_state(None) + await self._update_entity_state() self.async_on_remove( async_track_time_interval( self.hass, @@ -126,7 +126,7 @@ async def async_added_to_hass(self) -> None: ), ) - async def _update_entity_state(self, now) -> None: + async def _update_entity_state(self, now: datetime | None = None) -> None: """Update the state of the entity.""" if self._process_updates is None: self._process_updates = asyncio.Lock() diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 6b413712ed7651..93c007297ea0a0 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, cast from homeassistant.components.cover import CoverEntity @@ -147,7 +147,7 @@ def _query_state(self) -> str | None: if TYPE_CHECKING: return None - async def _update_entity_state(self, now) -> None: + async def _update_entity_state(self, now: datetime | None = None) -> None: """Update the state of the entity.""" if self._process_updates is None: self._process_updates = asyncio.Lock() @@ -186,14 +186,14 @@ async def async_update(self) -> None: async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.hass.async_add_executor_job(self._move_cover, self._command_open) - await self._update_entity_state(None) + await self._update_entity_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.hass.async_add_executor_job(self._move_cover, self._command_close) - await self._update_entity_state(None) + await self._update_entity_state() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.hass.async_add_executor_job(self._move_cover, self._command_stop) - await self._update_entity_state(None) + await self._update_entity_state() diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 99390e773579ae..c1d60b9d2fdb9c 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Mapping -from datetime import timedelta +from datetime import datetime, timedelta import json from typing import Any, cast @@ -108,7 +108,7 @@ def __init__( """Initialize the sensor.""" super().__init__(self.hass, config) self.data = data - self._attr_extra_state_attributes = {} + self._attr_extra_state_attributes: dict[str, Any] = {} self._json_attributes = json_attributes self._attr_native_value = None self._value_template = value_template @@ -118,12 +118,12 @@ def __init__( @property def extra_state_attributes(self) -> dict[str, Any]: """Return extra state attributes.""" - return cast(dict, self._attr_extra_state_attributes) + return self._attr_extra_state_attributes async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() - await self._update_entity_state(None) + await self._update_entity_state() self.async_on_remove( async_track_time_interval( self.hass, @@ -134,7 +134,7 @@ async def async_added_to_hass(self) -> None: ), ) - async def _update_entity_state(self, now) -> None: + async def _update_entity_state(self, now: datetime | None = None) -> None: """Update the state of the entity.""" if self._process_updates is None: self._process_updates = asyncio.Lock() diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 8d30de310ef959..0af6163312ce87 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, cast from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity @@ -155,7 +155,7 @@ def _query_state(self) -> str | int | None: if TYPE_CHECKING: return None - async def _update_entity_state(self, now) -> None: + async def _update_entity_state(self, now: datetime | None = None) -> None: """Update the state of the entity.""" if self._process_updates is None: self._process_updates = asyncio.Lock() @@ -197,11 +197,11 @@ async def async_turn_on(self, **kwargs: Any) -> None: if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True self.async_schedule_update_ha_state() - await self._update_entity_state(None) + await self._update_entity_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False self.async_schedule_update_ha_state() - await self._update_entity_state(None) + await self._update_entity_state() diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 29dd56c11ec054..193bd45bba03a6 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -42,6 +42,7 @@ ATTR_TEXT = "text" ATTR_LANGUAGE = "language" ATTR_AGENT_ID = "agent_id" +ATTR_CONVERSATION_ID = "conversation_id" DOMAIN = "conversation" @@ -66,6 +67,7 @@ def agent_id_validator(value: Any) -> str: vol.Required(ATTR_TEXT): cv.string, vol.Optional(ATTR_LANGUAGE): cv.string, vol.Optional(ATTR_AGENT_ID): agent_id_validator, + vol.Optional(ATTR_CONVERSATION_ID): cv.string, } ) @@ -164,7 +166,7 @@ async def handle_process(service: core.ServiceCall) -> core.ServiceResponse: result = await async_converse( hass=hass, text=text, - conversation_id=None, + conversation_id=service.data.get(ATTR_CONVERSATION_ID), context=service.context, language=service.data.get(ATTR_LANGUAGE), agent_id=service.data.get(ATTR_AGENT_ID), diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 9dcf70dda80e6b..e66c246dc44c46 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -19,7 +19,12 @@ TextSlotList, WildcardSlotList, ) -from hassil.recognize import RecognizeResult, recognize_all +from hassil.recognize import ( + RecognizeResult, + UnmatchedEntity, + UnmatchedTextEntity, + recognize_all, +) from hassil.util import merge_dict from home_assistant_intents import get_domains_and_languages, get_intents import yaml @@ -188,11 +193,15 @@ async def async_recognize( 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, + language, ) return result @@ -209,6 +218,7 @@ async def async_process(self, user_input: ConversationInput) -> ConversationResu lang_intents = self._lang_intents.get(language) if result is None: + # Intent was not recognized _LOGGER.debug("No intent was matched for '%s'", user_input.text) return _make_error_result( language, @@ -217,19 +227,43 @@ async def async_process(self, user_input: ConversationInput) -> ConversationResu conversation_id, ) + if result.unmatched_entities: + # Intent was recognized, but not entity/area names, etc. + _LOGGER.debug( + "Recognized intent '%s' for template '%s' but had unmatched: %s", + result.intent.name, + result.intent_sentence.text + if result.intent_sentence is not None + else "", + result.unmatched_entities_list, + ) + error_response_type, error_response_args = _get_unmatched_response( + result.unmatched_entities + ) + return _make_error_result( + language, + intent.IntentResponseErrorCode.NO_VALID_TARGETS, + self._get_error_text( + error_response_type, lang_intents, **error_response_args + ), + conversation_id, + ) + # Will never happen because result will be None when no intents are # 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 +311,18 @@ def _recognize( user_input: ConversationInput, lang_intents: LanguageIntents, slot_lists: dict[str, SlotList], + intent_context: dict[str, Any] | None, + language: str, ) -> 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, + language=language, ): if "name" in result.entities: return result @@ -290,7 +330,35 @@ def _recognize( # Keep looking in case an entity has the same name maybe_result = result - return maybe_result + if maybe_result is not None: + # Successful strict match + return maybe_result + + # Try again with missing entities enabled + for result in recognize_all( + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, + allow_unmatched_entities=True, + ): + if maybe_result is None: + # First result + maybe_result = result + elif len(result.unmatched_entities) < len(maybe_result.unmatched_entities): + # Fewer unmatched entities + maybe_result = result + elif len(result.unmatched_entities) == len(maybe_result.unmatched_entities): + if result.text_chunks_matched > maybe_result.text_chunks_matched: + # More literal text chunks matched + maybe_result = result + + if (maybe_result is not None) and maybe_result.unmatched_entities: + # Failed to match, but we have more information about why in unmatched_entities + return maybe_result + + # Complete match failure + return None async def _build_speech( self, @@ -623,16 +691,42 @@ def _make_slot_lists(self) -> dict[str, SlotList]: 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.id} + def _get_error_text( - self, response_type: ResponseType, lang_intents: LanguageIntents | None + self, + response_type: ResponseType, + lang_intents: LanguageIntents | None, + **response_args, ) -> str: """Get response error text by type.""" if lang_intents is None: return _DEFAULT_ERROR_TEXT response_key = response_type.value - response_str = lang_intents.error_responses.get(response_key) - return response_str or _DEFAULT_ERROR_TEXT + response_str = ( + lang_intents.error_responses.get(response_key) or _DEFAULT_ERROR_TEXT + ) + response_template = template.Template(response_str, self.hass) + + return response_template.async_render(response_args) def register_trigger( self, @@ -752,6 +846,27 @@ def _make_error_result( return ConversationResult(response, conversation_id) +def _get_unmatched_response( + unmatched_entities: dict[str, UnmatchedEntity], +) -> tuple[ResponseType, dict[str, Any]]: + error_response_type = ResponseType.NO_INTENT + error_response_args: dict[str, Any] = {} + + if unmatched_name := unmatched_entities.get("name"): + # Unmatched device or entity + assert isinstance(unmatched_name, UnmatchedTextEntity) + error_response_type = ResponseType.NO_ENTITY + error_response_args["entity"] = unmatched_name.text + + elif unmatched_area := unmatched_entities.get("area"): + # Unmatched area + assert isinstance(unmatched_area, UnmatchedTextEntity) + error_response_type = ResponseType.NO_AREA + error_response_args["area"] = unmatched_area.text + + return error_response_type, error_response_args + + def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" if isinstance(expression, Sequence): diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 1b4d346082af60..5de11d7a41acca 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.2", "home-assistant-intents==2024.1.2"] } diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index 953db065614416..3846426c3f07db 100644 --- a/homeassistant/components/conversation/services.yaml +++ b/homeassistant/components/conversation/services.yaml @@ -14,6 +14,10 @@ process: example: homeassistant selector: conversation_agent: + conversation_id: + example: my_conversation_1 + selector: + text: reload: fields: diff --git a/homeassistant/components/conversation/strings.json b/homeassistant/components/conversation/strings.json index 8240cfa3f82cdc..255e6cec430486 100644 --- a/homeassistant/components/conversation/strings.json +++ b/homeassistant/components/conversation/strings.json @@ -16,6 +16,10 @@ "agent_id": { "name": "Agent", "description": "Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands." + }, + "conversation_id": { + "name": "Conversation ID", + "description": "ID of the conversation, to be able to continue a previous conversation" } } }, diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 7baa6444c1dbb2..17deab306df6dd 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 42676498c9f3b3..7d69025fb97f74 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Self +from typing import Any, Self, TypeVar import voluptuous as vol @@ -22,6 +22,8 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType +_T = TypeVar("_T") + _LOGGER = logging.getLogger(__name__) ATTR_INITIAL = "initial" @@ -59,7 +61,7 @@ } -def _none_to_empty_dict(value): +def _none_to_empty_dict(value: _T | None) -> _T | dict[str, Any]: if value is None: return {} return value @@ -140,12 +142,12 @@ class CounterStorageCollection(collection.DictStorageCollection): async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" - return self.CREATE_UPDATE_SCHEMA(data) + return self.CREATE_UPDATE_SCHEMA(data) # type: ignore[no-any-return] @callback def _get_suggested_id(self, info: dict) -> str: """Suggest an ID based on the config.""" - return info[CONF_NAME] + return info[CONF_NAME] # type: ignore[no-any-return] async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" @@ -211,9 +213,9 @@ def extra_state_attributes(self) -> dict: @property def unique_id(self) -> str | None: """Return unique id of the entity.""" - return self._config[CONF_ID] + return self._config[CONF_ID] # type: ignore[no-any-return] - def compute_next_state(self, state) -> int: + def compute_next_state(self, state: int | None) -> int | None: """Keep the state within the range of min/max values.""" if self._config[CONF_MINIMUM] is not None: state = max(self._config[CONF_MINIMUM], state) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 354b972e2b78fb..3e438fb4ca1303 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag, StrEnum import functools as ft import logging -from typing import Any, ParamSpec, TypeVar, final +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, final import voluptuous as vol @@ -33,11 +32,21 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) DOMAIN = "cover" @@ -70,16 +79,32 @@ class CoverDeviceClass(StrEnum): # DEVICE_CLASS* below are deprecated as of 2021.12 # use the CoverDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass] -DEVICE_CLASS_AWNING = CoverDeviceClass.AWNING.value -DEVICE_CLASS_BLIND = CoverDeviceClass.BLIND.value -DEVICE_CLASS_CURTAIN = CoverDeviceClass.CURTAIN.value -DEVICE_CLASS_DAMPER = CoverDeviceClass.DAMPER.value -DEVICE_CLASS_DOOR = CoverDeviceClass.DOOR.value -DEVICE_CLASS_GARAGE = CoverDeviceClass.GARAGE.value -DEVICE_CLASS_GATE = CoverDeviceClass.GATE.value -DEVICE_CLASS_SHADE = CoverDeviceClass.SHADE.value -DEVICE_CLASS_SHUTTER = CoverDeviceClass.SHUTTER.value -DEVICE_CLASS_WINDOW = CoverDeviceClass.WINDOW.value +_DEPRECATED_DEVICE_CLASS_AWNING = DeprecatedConstantEnum( + CoverDeviceClass.AWNING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_BLIND = DeprecatedConstantEnum( + CoverDeviceClass.BLIND, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CURTAIN = DeprecatedConstantEnum( + CoverDeviceClass.CURTAIN, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DAMPER = DeprecatedConstantEnum( + CoverDeviceClass.DAMPER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum(CoverDeviceClass.DOOR, "2025.1") +_DEPRECATED_DEVICE_CLASS_GARAGE = DeprecatedConstantEnum( + CoverDeviceClass.GARAGE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GATE = DeprecatedConstantEnum(CoverDeviceClass.GATE, "2025.1") +_DEPRECATED_DEVICE_CLASS_SHADE = DeprecatedConstantEnum( + CoverDeviceClass.SHADE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SHUTTER = DeprecatedConstantEnum( + CoverDeviceClass.SHUTTER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( + CoverDeviceClass.WINDOW, "2025.1" +) # mypy: disallow-any-generics @@ -99,14 +124,28 @@ class CoverEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the CoverEntityFeature enum instead. -SUPPORT_OPEN = 1 -SUPPORT_CLOSE = 2 -SUPPORT_SET_POSITION = 4 -SUPPORT_STOP = 8 -SUPPORT_OPEN_TILT = 16 -SUPPORT_CLOSE_TILT = 32 -SUPPORT_STOP_TILT = 64 -SUPPORT_SET_TILT_POSITION = 128 +_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(CoverEntityFeature.OPEN, "2025.1") +_DEPRECATED_SUPPORT_CLOSE = DeprecatedConstantEnum(CoverEntityFeature.CLOSE, "2025.1") +_DEPRECATED_SUPPORT_SET_POSITION = DeprecatedConstantEnum( + CoverEntityFeature.SET_POSITION, "2025.1" +) +_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(CoverEntityFeature.STOP, "2025.1") +_DEPRECATED_SUPPORT_OPEN_TILT = DeprecatedConstantEnum( + CoverEntityFeature.OPEN_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_CLOSE_TILT = DeprecatedConstantEnum( + CoverEntityFeature.CLOSE_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_STOP_TILT = DeprecatedConstantEnum( + CoverEntityFeature.STOP_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_SET_TILT_POSITION = DeprecatedConstantEnum( + CoverEntityFeature.SET_TILT_POSITION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) ATTR_CURRENT_POSITION = "current_position" ATTR_CURRENT_TILT_POSITION = "current_tilt_position" @@ -212,14 +251,23 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class CoverEntityDescription(EntityDescription): +class CoverEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes cover entities.""" device_class: CoverDeviceClass | None = None -class CoverEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "current_cover_position", + "current_cover_tilt_position", + "device_class", + "is_opening", + "is_closing", + "is_closed", +} + + +class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for cover entities.""" entity_description: CoverEntityDescription @@ -234,7 +282,7 @@ class CoverEntity(Entity): _cover_is_last_toggle_direction_open = True - @property + @cached_property def current_cover_position(self) -> int | None: """Return current position of cover. @@ -242,7 +290,7 @@ def current_cover_position(self) -> int | None: """ return self._attr_current_cover_position - @property + @cached_property def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt. @@ -250,7 +298,7 @@ def current_cover_tilt_position(self) -> int | None: """ return self._attr_current_cover_tilt_position - @property + @cached_property def device_class(self) -> CoverDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -292,8 +340,12 @@ def state_attributes(self) -> dict[str, Any]: @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" - if self._attr_supported_features is not None: - return self._attr_supported_features + if (features := self._attr_supported_features) is not None: + if type(features) is int: # noqa: E721 + new_features = CoverEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP @@ -312,17 +364,17 @@ def supported_features(self) -> CoverEntityFeature: return supported_features - @property + @cached_property def is_opening(self) -> bool | None: """Return if the cover is opening or not.""" return self._attr_is_opening - @property + @cached_property def is_closing(self) -> bool | None: """Return if the cover is closing or not.""" return self._attr_is_closing - @property + @cached_property def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" return self._attr_is_closed diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index e34a623be937d8..2224e5bab1c767 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -24,18 +24,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ( - ATTR_POSITION, - ATTR_TILT_POSITION, - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_CLOSE_TILT, - SUPPORT_OPEN, - SUPPORT_OPEN_TILT, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, - SUPPORT_STOP, -) +from . import ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, CoverEntityFeature CMD_ACTION_TYPES = {"open", "close", "stop", "open_tilt", "close_tilt"} POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} @@ -88,20 +77,20 @@ async def async_get_actions( CONF_ENTITY_ID: entry.id, } - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: actions.append({**base_action, CONF_TYPE: "set_position"}) - if supported_features & SUPPORT_OPEN: + if supported_features & CoverEntityFeature.OPEN: actions.append({**base_action, CONF_TYPE: "open"}) - if supported_features & SUPPORT_CLOSE: + if supported_features & CoverEntityFeature.CLOSE: actions.append({**base_action, CONF_TYPE: "close"}) - if supported_features & SUPPORT_STOP: + if supported_features & CoverEntityFeature.STOP: actions.append({**base_action, CONF_TYPE: "stop"}) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: actions.append({**base_action, CONF_TYPE: "set_tilt_position"}) - if supported_features & SUPPORT_OPEN_TILT: + if supported_features & CoverEntityFeature.OPEN_TILT: actions.append({**base_action, CONF_TYPE: "open_tilt"}) - if supported_features & SUPPORT_CLOSE_TILT: + if supported_features & CoverEntityFeature.CLOSE_TILT: actions.append({**base_action, CONF_TYPE: "close_tilt"}) return actions diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index 2aa0a1dd2fb845..23ec7d7565029f 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -26,13 +26,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ( - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, -) +from . import DOMAIN, CoverEntityFeature # mypy: disallow-any-generics @@ -78,7 +72,9 @@ async def async_get_conditions( continue supported_features = get_supported_features(hass, entry.entity_id) - supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + supports_open_close = supported_features & ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) # Add conditions for each entity that belongs to this integration base_condition = { @@ -92,9 +88,9 @@ async def async_get_conditions( conditions += [ {**base_condition, CONF_TYPE: cond} for cond in STATE_CONDITION_TYPES ] - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: conditions.append({**base_condition, CONF_TYPE: "is_position"}) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: conditions.append({**base_condition, CONF_TYPE: "is_tilt_position"}) return conditions diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index 2fb456d726d74c..8225348619d784 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -29,13 +29,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import ( - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, -) +from . import DOMAIN, CoverEntityFeature POSITION_TRIGGER_TYPES = {"position", "tilt_position"} STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} @@ -80,7 +74,9 @@ async def async_get_triggers( continue supported_features = get_supported_features(hass, entry.entity_id) - supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + supports_open_close = supported_features & ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) # Add triggers for each entity that belongs to this integration base_trigger = { @@ -98,14 +94,14 @@ async def async_get_triggers( } for trigger in STATE_TRIGGER_TYPES ] - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: triggers.append( { **base_trigger, CONF_TYPE: "position", } ) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: triggers.append( { **base_trigger, diff --git a/homeassistant/components/cover/significant_change.py b/homeassistant/components/cover/significant_change.py new file mode 100644 index 00000000000000..ca822c5e9e1c43 --- /dev/null +++ b/homeassistant/components/cover/significant_change.py @@ -0,0 +1,56 @@ +"""Helper to test significant Cover state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 1646e292ee90c2..9e7a181ba325a8 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -39,14 +39,14 @@ ) -@dataclass +@dataclass(frozen=True) class DaikinRequiredKeysMixin: """Mixin for required keys.""" value_func: Callable[[Appliance], float | None] -@dataclass +@dataclass(frozen=True) class DaikinSensorEntityDescription(SensorEntityDescription, DaikinRequiredKeysMixin): """Describes Daikin sensor entity.""" diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 8dd759166851fe..7acd234e397486 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -13,8 +13,10 @@ 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 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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/date/__init__.py b/homeassistant/components/date/__init__.py index 51f3a492c47a42..00ec09043c9690 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -1,10 +1,9 @@ """Component to allow setting date as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import date, timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -22,6 +21,12 @@ from .const import DOMAIN, SERVICE_SET_VALUE +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -62,12 +67,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class DateEntityDescription(EntityDescription): +class DateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes date entities.""" -class DateEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = {"native_value"} + + +class DateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Date entity.""" entity_description: DateEntityDescription @@ -75,13 +82,13 @@ class DateEntity(Entity): _attr_native_value: date | None _attr_state: None = None - @property + @cached_property @final def device_class(self) -> None: """Return the device class for the entity.""" return None - @property + @cached_property @final def state_attributes(self) -> None: """Return the state attributes.""" @@ -95,7 +102,7 @@ def state(self) -> str | None: return None return self.native_value.isoformat() - @property + @cached_property def native_value(self) -> date | None: """Return the value reported by the date.""" return self._attr_native_value diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index e25f4535d0c1a1..9a509aadc7006a 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -1,10 +1,9 @@ """Component to allow setting date/time as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import UTC, datetime, timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -22,6 +21,11 @@ from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -71,12 +75,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class DateTimeEntityDescription(EntityDescription): +class DateTimeEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes date/time entities.""" -class DateTimeEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "native_value", +} + + +class DateTimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Date/time entity.""" entity_description: DateTimeEntityDescription @@ -84,13 +92,13 @@ class DateTimeEntity(Entity): _attr_state: None = None _attr_native_value: datetime | None - @property + @cached_property @final def device_class(self) -> None: """Return entity device class.""" return None - @property + @cached_property @final def state_attributes(self) -> None: """Return the state attributes.""" @@ -110,7 +118,7 @@ def state(self) -> str | None: return value.astimezone(UTC).isoformat(timespec="seconds") - @property + @cached_property def native_value(self) -> datetime | None: """Return the value reported by the datetime.""" return self._attr_native_value diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 114e401346d92f..c0a4e2585a3772 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -65,24 +65,15 @@ ) -@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(frozen=True, 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 318e0e43beb101..52105c10203d4c 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -23,17 +23,12 @@ from .gateway import DeconzGateway, get_gateway_from_config_entry -@dataclass -class DeconzButtonDescriptionMixin: - """Required values when describing deCONZ button entities.""" +@dataclass(frozen=True, kw_only=True) +class DeconzButtonDescription(ButtonEntityDescription): + """Class describing deCONZ button entities.""" - suffix: str button_fn: str - - -@dataclass -class DeconzButtonDescription(ButtonEntityDescription, DeconzButtonDescriptionMixin): - """Class describing deCONZ button entities.""" + suffix: str ENTITY_DESCRIPTIONS = { diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 4c0f35266f90f5..8a5ced2c67844c 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -129,9 +129,8 @@ def async_update_callback(self) -> None: 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/light.py b/homeassistant/components/deconz/light.py index dc2ed04b4ed8b3..044c9bf203b37e 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -67,7 +67,7 @@ LightColorMode.XY: ColorMode.XY, } -TS0601_EFFECTS = [ +XMAS_LIGHT_EFFECTS = [ "carnival", "collide", "fading", @@ -200,8 +200,8 @@ def __init__(self, device: _LightDeviceT, gateway: DeconzGateway) -> None: if device.effect is not None: self._attr_supported_features |= LightEntityFeature.EFFECT self._attr_effect_list = [EFFECT_COLORLOOP] - if device.model_id == "TS0601": - self._attr_effect_list += TS0601_EFFECTS + if device.model_id in ("HG06467", "TS0601"): + self._attr_effect_list = XMAS_LIGHT_EFFECTS @property def color_mode(self) -> str | None: diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 6245558a1c55a7..af1824e441ceb5 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 ec4438502b67d1..e98f5d726aceaf 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -31,9 +31,9 @@ T = TypeVar("T", Presence, PydeconzSensorBase) -@dataclass -class DeconzNumberDescriptionMixin(Generic[T]): - """Required values when describing deCONZ number entities.""" +@dataclass(frozen=True, 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 4e00ac0a41525e..8366c81131855a 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -17,6 +17,7 @@ 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 @@ Humidity, LightLevel, Moisture, + ParticulateMatter, Power, Pressure, Temperature, @@ -91,22 +93,16 @@ ) -@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(frozen=True, 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 @@ class DeconzSensorDescription(SensorEntityDescription, DeconzSensorDescriptionMi 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 e32ab875c28bb6..c06a07e6ce5c13 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/decora/light.py b/homeassistant/components/decora/light.py index d060b69c3f6928..4a56b72ec661c9 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -60,7 +60,7 @@ def _name_validator(config): def retry( - method: Callable[Concatenate[_DecoraLightT, _P], _R] + method: Callable[Concatenate[_DecoraLightT, _P], _R], ) -> Callable[Concatenate[_DecoraLightT, _P], _R | None]: """Retry bluetooth commands.""" diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 9242e3e2d5ed2e..eeb947663bfece 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -38,7 +38,7 @@ def get_state(data: dict[str, float], key: str) -> str | float: return round(kb_spd, 2 if kb_spd < 0.1 else 1) -@dataclass +@dataclass(frozen=True) class DelugeSensorEntityDescription(SensorEntityDescription): """Class to describe a Deluge sensor.""" diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index e0266d004e2b8b..52706f3989456b 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/camera.py b/homeassistant/components/demo/camera.py index 722693280a0222..502129b5c9d768 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -19,6 +19,7 @@ async def async_setup_entry( [ DemoCamera("Demo camera", "image/jpg"), DemoCamera("Demo camera png", "image/png"), + DemoCameraWithoutStream("Demo camera without stream", "image/jpg"), ] ) @@ -28,7 +29,7 @@ class DemoCamera(Camera): _attr_is_streaming = True _attr_motion_detection_enabled = False - _attr_supported_features = CameraEntityFeature.ON_OFF + _attr_supported_features = CameraEntityFeature.ON_OFF | CameraEntityFeature.STREAM def __init__(self, name: str, content_type: str) -> None: """Initialize demo camera component.""" @@ -68,3 +69,9 @@ async def async_turn_on(self) -> None: self._attr_is_streaming = True self._attr_is_on = True self.async_write_ha_state() + + +class DemoCameraWithoutStream(DemoCamera): + """The representation of a Demo camera without stream.""" + + _attr_supported_features = CameraEntityFeature.ON_OFF diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 1e585b12acd420..0eaa7d5f41f494 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -73,7 +73,7 @@ async def async_setup_entry( target_temperature=None, unit_of_measurement=UnitOfTemperature.CELSIUS, preset="home", - preset_modes=["home", "eco"], + preset_modes=["home", "eco", "away"], current_temperature=23, fan_mode="Auto Low", target_humidity=None, diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 211389a54663a6..73cae4a64b1636 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -161,12 +161,9 @@ def preset_modes(self) -> list[str] | None: 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 @@ def preset_modes(self) -> list[str] | None: 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/demo/media_player.py b/homeassistant/components/demo/media_player.py index 35bd35a22455c1..b0b2e1a95f5a51 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -38,6 +38,8 @@ async def async_setup_entry( DemoMusicPlayer(), DemoMusicPlayer("Kitchen"), DemoTVShowPlayer(), + DemoBrowsePlayer("Browse"), + DemoGroupPlayer("Group"), ] ) @@ -90,6 +92,8 @@ async def async_setup_entry( | MediaPlayerEntityFeature.STOP ) +BROWSE_PLAYER_SUPPORT = MediaPlayerEntityFeature.BROWSE_MEDIA + class AbstractDemoPlayer(MediaPlayerEntity): """A demo media players.""" @@ -379,3 +383,19 @@ def select_source(self, source: str) -> None: """Set the input source.""" self._attr_source = source self.schedule_update_ha_state() + + +class DemoBrowsePlayer(AbstractDemoPlayer): + """A Demo media player that supports browse.""" + + _attr_supported_features = BROWSE_PLAYER_SUPPORT + + +class DemoGroupPlayer(AbstractDemoPlayer): + """A Demo media player that supports grouping.""" + + _attr_supported_features = ( + YOUTUBE_PLAYER_SUPPORT + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.TURN_OFF + ) diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 8b6907a60f7ad3..b0454784ca1a49 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -368,11 +368,6 @@ def supported_features(self) -> MediaPlayerEntityFeature: return self._supported_features_base | SUPPORT_MEDIA_MODES return self._supported_features_base - @property - def media_content_id(self): - """Content ID of current playing media.""" - return None - @property def media_content_type(self) -> MediaType: """Content type of current playing media.""" @@ -380,11 +375,6 @@ def media_content_type(self) -> MediaType: return MediaType.MUSIC return MediaType.CHANNEL - @property - def media_duration(self): - """Duration of current playing media in seconds.""" - return None - @property def media_image_url(self): """Image url of current playing media.""" @@ -415,44 +405,19 @@ def media_album_name(self): return self._receiver.album return self._receiver.station - @property - def media_album_artist(self): - """Album artist of current playing media, music track only.""" - return None - - @property - def media_track(self): - """Track number of current playing media, music track only.""" - return None - - @property - def media_series_title(self): - """Title of series of current playing media, TV show only.""" - return None - - @property - def media_season(self): - """Season of current playing media, TV show only.""" - return None - - @property - def media_episode(self): - """Episode of current playing media, TV show only.""" - return None - @property def extra_state_attributes(self): """Return device specific state attributes.""" - if self._receiver.power != POWER_ON: + receiver = self._receiver + if receiver.power != POWER_ON: return {} state_attributes = {} if ( - self._receiver.sound_mode_raw is not None - and self._receiver.support_sound_mode - ): - state_attributes[ATTR_SOUND_MODE_RAW] = self._receiver.sound_mode_raw - if self._receiver.dynamic_eq is not None: - state_attributes[ATTR_DYNAMIC_EQ] = self._receiver.dynamic_eq + sound_mode_raw := receiver.sound_mode_raw + ) is not None and receiver.support_sound_mode: + state_attributes[ATTR_SOUND_MODE_RAW] = sound_mode_raw + if (dynamic_eq := receiver.dynamic_eq) is not None: + state_attributes[ATTR_DYNAMIC_EQ] = dynamic_eq return state_attributes @property diff --git a/homeassistant/components/devialet/__init__.py b/homeassistant/components/devialet/__init__.py new file mode 100644 index 00000000000000..034f93abb68da2 --- /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 00000000000000..de52788de50ee2 --- /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 00000000000000..ccb4fbc7964441 --- /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 00000000000000..9e1eada7183ab4 --- /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 00000000000000..f9824a9cad194b --- /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 00000000000000..dd30f91c8354ae --- /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.5"], + "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 00000000000000..a79a82e6f60770 --- /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 00000000000000..0a90da49bf4c8e --- /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 00000000000000..af0cfc4c122795 --- /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_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index d7641c34316dc1..68d05c19f67763 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -358,7 +358,7 @@ def async_validate_entity_schema( def handle_device_errors( - func: Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], Awaitable[None]] + func: Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], Awaitable[None]], ) -> Callable[ [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] ]: diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index a6a8e9d2d8ced9..b5ad4660cde215 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,8 +1,14 @@ """Provide functionality to keep track of devices.""" from __future__ import annotations +from functools import partial + from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401 from homeassistant.core import HomeAssistant +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -13,6 +19,10 @@ async_unload_entry, ) from .const import ( # noqa: F401 + _DEPRECATED_SOURCE_TYPE_BLUETOOTH, + _DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE, + _DEPRECATED_SOURCE_TYPE_GPS, + _DEPRECATED_SOURCE_TYPE_ROUTER, ATTR_ATTRIBUTES, ATTR_BATTERY, ATTR_DEV_ID, @@ -32,10 +42,6 @@ DOMAIN, ENTITY_ID_FORMAT, SCAN_INTERVAL, - SOURCE_TYPE_BLUETOOTH, - SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS, - SOURCE_TYPE_ROUTER, SourceType, ) from .legacy import ( # noqa: F401 @@ -51,6 +57,12 @@ see, ) +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + @bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 3a0b0afd7c9fbf..10c16e09107026 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -3,9 +3,16 @@ from datetime import timedelta from enum import StrEnum +from functools import partial import logging from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + LOGGER: Final = logging.getLogger(__package__) DOMAIN: Final = "device_tracker" @@ -14,13 +21,6 @@ PLATFORM_TYPE_LEGACY: Final = "legacy" PLATFORM_TYPE_ENTITY: Final = "entity_platform" -# SOURCE_TYPE_* below are deprecated as of 2022.9 -# use the SourceType enum instead. -SOURCE_TYPE_GPS: Final = "gps" -SOURCE_TYPE_ROUTER: Final = "router" -SOURCE_TYPE_BLUETOOTH: Final = "bluetooth" -SOURCE_TYPE_BLUETOOTH_LE: Final = "bluetooth_le" - class SourceType(StrEnum): """Source type for device trackers.""" @@ -31,6 +31,23 @@ class SourceType(StrEnum): BLUETOOTH_LE = "bluetooth_le" +# SOURCE_TYPE_* below are deprecated as of 2022.9 +# use the SourceType enum instead. +_DEPRECATED_SOURCE_TYPE_GPS: Final = DeprecatedConstantEnum(SourceType.GPS, "2025.1") +_DEPRECATED_SOURCE_TYPE_ROUTER: Final = DeprecatedConstantEnum( + SourceType.ROUTER, "2025.1" +) +_DEPRECATED_SOURCE_TYPE_BLUETOOTH: Final = DeprecatedConstantEnum( + SourceType.BLUETOOTH, "2025.1" +) +_DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE: Final = DeprecatedConstantEnum( + SourceType.BLUETOOTH_LE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + CONF_SCAN_INTERVAL: Final = "interval_seconds" SCAN_INTERVAL: Final = timedelta(seconds=12) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 7c12a2d8777b38..a17972526cfa64 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,7 +14,11 @@ 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, + config_per_platform, + load_yaml_config_file, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, @@ -33,7 +37,6 @@ from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( - config_per_platform, config_validation as cv, discovery, entity_registry as er, @@ -44,7 +47,11 @@ ) 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 @@ -280,7 +287,7 @@ async def async_setup_legacy( ) -> None: """Set up a legacy platform.""" assert self.type == PLATFORM_TYPE_LEGACY - full_name = f"{DOMAIN}.{self.name}" + full_name = f"{self.name}.{DOMAIN}" LOGGER.info("Setting up %s", full_name) with async_start_setup(hass, [full_name]): try: @@ -1006,7 +1013,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 +1036,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/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml index 08ccbcf0b5a921..3199dfd8af1ee1 100644 --- a/homeassistant/components/device_tracker/services.yaml +++ b/homeassistant/components/device_tracker/services.yaml @@ -25,9 +25,9 @@ see: gps_accuracy: selector: number: - min: 1 - max: 100 - unit_of_measurement: "%" + min: 0 + mode: box + unit_of_measurement: "m" battery: selector: number: diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index 71ca03f9638b90..eb85e82755111e 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_push", "loggers": ["devolo_home_control_api"], "quality_scale": "gold", - "requirements": ["devolo-home-control-api==0.18.2"], + "requirements": ["devolo-home-control-api==0.18.3"], "zeroconf": ["_dvl-deviceapi._tcp.local."] } diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 0fee65d57b6f69..842d1bee40fd51 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/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index ebe7e60af7bb66..35b79b57f1d498 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -32,14 +32,14 @@ def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool: ) -@dataclass +@dataclass(frozen=True) class DevoloBinarySensorRequiredKeysMixin: """Mixin for required keys.""" value_func: Callable[[DevoloBinarySensorEntity], bool] -@dataclass +@dataclass(frozen=True) class DevoloBinarySensorEntityDescription( BinarySensorEntityDescription, DevoloBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py index 463356268a64bc..9b3dd75ef98898 100644 --- a/homeassistant/components/devolo_home_network/button.py +++ b/homeassistant/components/devolo_home_network/button.py @@ -22,14 +22,14 @@ from .entity import DevoloEntity -@dataclass +@dataclass(frozen=True) class DevoloButtonRequiredKeysMixin: """Mixin for required keys.""" press_func: Callable[[Device], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class DevoloButtonEntityDescription( ButtonEntityDescription, DevoloButtonRequiredKeysMixin ): diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index aaee8051cb5181..4caa4f5b60b8fb 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -25,6 +25,8 @@ IMAGE_GUEST_WIFI = "image_guest_wifi" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" PAIRING = "pairing" +PLC_RX_RATE = "plc_rx_rate" +PLC_TX_RATE = "plc_tx_rate" REGULAR_FIRMWARE = "regular_firmware" RESTART = "restart" START_WPS = "start_wps" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a0aa0466d90875..d6ddf6614944bb 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -9,7 +9,7 @@ NeighborAPInfo, WifiGuestAccessGet, ) -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -25,6 +25,7 @@ "_DataT", bound=( LogicalNetwork + | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo] | WifiGuestAccessGet diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index 3670c42bc6bc98..72cf4f57c1dc9a 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -21,14 +21,14 @@ from .entity import DevoloCoordinatorEntity -@dataclass +@dataclass(frozen=True) class DevoloImageRequiredKeysMixin: """Mixin for required keys.""" image_func: Callable[[WifiGuestAccessGet], bytes] -@dataclass +@dataclass(frozen=True) class DevoloImageEntityDescription( ImageEntityDescription, DevoloImageRequiredKeysMixin ): diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 7a6da1f41a573f..66395e3a465702 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -3,19 +3,21 @@ from collections.abc import Callable from dataclasses import dataclass +from enum import StrEnum from typing import Any, Generic, TypeVar from devolo_plc_api.device import Device from devolo_plc_api.device_api import ConnectedStationInfo, NeighborAPInfo -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import REMOTE, DataRate, LogicalNetwork from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -25,25 +27,38 @@ CONNECTED_WIFI_CLIENTS, DOMAIN, NEIGHBORING_WIFI_NETWORKS, + PLC_RX_RATE, + PLC_TX_RATE, ) from .entity import DevoloCoordinatorEntity -_DataT = TypeVar( - "_DataT", - bound=LogicalNetwork | list[ConnectedStationInfo] | list[NeighborAPInfo], +_CoordinatorDataT = TypeVar( + "_CoordinatorDataT", + bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], ) +_ValueDataT = TypeVar( + "_ValueDataT", + bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], +) + + +class DataRateDirection(StrEnum): + """Direction of data transfer.""" + RX = "rx_rate" + TX = "tx_rate" -@dataclass -class DevoloSensorRequiredKeysMixin(Generic[_DataT]): + +@dataclass(frozen=True) +class DevoloSensorRequiredKeysMixin(Generic[_CoordinatorDataT]): """Mixin for required keys.""" - value_func: Callable[[_DataT], int] + value_func: Callable[[_CoordinatorDataT], float] -@dataclass +@dataclass(frozen=True) class DevoloSensorEntityDescription( - SensorEntityDescription, DevoloSensorRequiredKeysMixin[_DataT] + SensorEntityDescription, DevoloSensorRequiredKeysMixin[_CoordinatorDataT] ): """Describes devolo sensor entity.""" @@ -71,6 +86,24 @@ class DevoloSensorEntityDescription( icon="mdi:wifi-marker", value_func=len, ), + PLC_RX_RATE: DevoloSensorEntityDescription[DataRate]( + key=PLC_RX_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + name="PLC downlink PHY rate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_func=lambda data: getattr(data, DataRateDirection.RX, 0), + suggested_display_precision=0, + ), + PLC_TX_RATE: DevoloSensorEntityDescription[DataRate]( + key=PLC_TX_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + name="PLC uplink PHY rate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_func=lambda data: getattr(data, DataRateDirection.TX, 0), + suggested_display_precision=0, + ), } @@ -83,7 +116,7 @@ async def async_setup_entry( entry.entry_id ]["coordinators"] - entities: list[DevoloSensorEntity[Any]] = [] + entities: list[BaseDevoloSensorEntity[Any, Any]] = [] if device.plcnet: entities.append( DevoloSensorEntity( @@ -93,6 +126,29 @@ async def async_setup_entry( device, ) ) + network = await device.plcnet.async_get_network_overview() + peers = [ + peer.mac_address for peer in network.devices if peer.topology == REMOTE + ] + for peer in peers: + entities.append( + DevoloPlcDataRateSensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[PLC_TX_RATE], + device, + peer, + ) + ) + entities.append( + DevoloPlcDataRateSensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[PLC_RX_RATE], + device, + peer, + ) + ) if device.device and "wifi1" in device.device.features: entities.append( DevoloSensorEntity( @@ -113,23 +169,70 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloSensorEntity(DevoloCoordinatorEntity[_DataT], SensorEntity): +class BaseDevoloSensorEntity( + Generic[_CoordinatorDataT, _ValueDataT], + DevoloCoordinatorEntity[_CoordinatorDataT], + SensorEntity, +): """Representation of a devolo sensor.""" - entity_description: DevoloSensorEntityDescription[_DataT] - def __init__( self, entry: ConfigEntry, - coordinator: DataUpdateCoordinator[_DataT], - description: DevoloSensorEntityDescription[_DataT], + coordinator: DataUpdateCoordinator[_CoordinatorDataT], + description: DevoloSensorEntityDescription[_ValueDataT], device: Device, ) -> None: """Initialize entity.""" self.entity_description = description super().__init__(entry, coordinator, device) + +class DevoloSensorEntity(BaseDevoloSensorEntity[_CoordinatorDataT, _CoordinatorDataT]): + """Representation of a generic devolo sensor.""" + + entity_description: DevoloSensorEntityDescription[_CoordinatorDataT] + @property - def native_value(self) -> int: + def native_value(self) -> float: """State of the sensor.""" return self.entity_description.value_func(self.coordinator.data) + + +class DevoloPlcDataRateSensorEntity(BaseDevoloSensorEntity[LogicalNetwork, DataRate]): + """Representation of a devolo PLC data rate sensor.""" + + entity_description: DevoloSensorEntityDescription[DataRate] + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator[LogicalNetwork], + description: DevoloSensorEntityDescription[DataRate], + device: Device, + peer: str, + ) -> None: + """Initialize entity.""" + super().__init__(entry, coordinator, description, device) + self._peer = peer + peer_device = next( + device + for device in self.coordinator.data.devices + if device.mac_address == peer + ) + + self._attr_unique_id = f"{self._attr_unique_id}_{peer}" + self._attr_name = f"{description.name} ({peer_device.user_device_name})" + self._attr_entity_registry_enabled_default = peer_device.attached_to_router + + @property + def native_value(self) -> float: + """State of the sensor.""" + return self.entity_description.value_func( + next( + data_rate + for data_rate in self.coordinator.data.data_rates + if data_rate.mac_address_from == self.device.mac + and data_rate.mac_address_to == self._peer + ) + ) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 55a7920ab3e181..1362417c12561b 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -62,6 +62,12 @@ }, "neighboring_wifi_networks": { "name": "Neighboring Wifi networks" + }, + "plc_rx_rate": { + "name": "PLC downlink PHY rate" + }, + "plc_tx_rate": { + "name": "PLC uplink PHY rate" } }, "switch": { diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index e7bcee3f2ece84..99c23f77d35dfe 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -23,7 +23,7 @@ _DataT = TypeVar("_DataT", bound=WifiGuestAccessGet | bool) -@dataclass +@dataclass(frozen=True) class DevoloSwitchRequiredKeysMixin(Generic[_DataT]): """Mixin for required keys.""" @@ -32,7 +32,7 @@ class DevoloSwitchRequiredKeysMixin(Generic[_DataT]): turn_off_func: Callable[[Device], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class DevoloSwitchEntityDescription( SwitchEntityDescription, DevoloSwitchRequiredKeysMixin[_DataT] ): diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index 1c95c4262b2dc9..03f86381307691 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -26,7 +26,7 @@ from .entity import DevoloCoordinatorEntity -@dataclass +@dataclass(frozen=True) class DevoloUpdateRequiredKeysMixin: """Mixin for required keys.""" @@ -34,7 +34,7 @@ class DevoloUpdateRequiredKeysMixin: update_func: Callable[[Device], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class DevoloUpdateEntityDescription( UpdateEntityDescription, DevoloUpdateRequiredKeysMixin ): diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index c3705dad3ddbbe..b8a12a937e33b2 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -9,7 +9,6 @@ from datetime import timedelta from fnmatch import translate from functools import lru_cache -from ipaddress import ip_address as make_ip_address import logging import os import re @@ -22,6 +21,7 @@ IP_ADDRESS as DISCOVERY_IP_ADDRESS, MAC_ADDRESS as DISCOVERY_MAC_ADDRESS, ) +from cached_ipaddress import cached_ip_addresses from scapy.config import conf from scapy.error import Scapy_Exception @@ -57,7 +57,6 @@ ) from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import DHCPMatcher, async_get_dhcp -from homeassistant.util.async_ import run_callback_threadsafe from .const import DOMAIN @@ -145,20 +144,19 @@ async def async_start(self) -> None: def process_client(self, ip_address: str, hostname: str, mac_address: str) -> None: """Process a client.""" - return run_callback_threadsafe( - self.hass.loop, - self.async_process_client, - ip_address, - hostname, - mac_address, - ).result() + self.hass.loop.call_soon_threadsafe( + self.async_process_client, ip_address, hostname, mac_address + ) @callback def async_process_client( self, ip_address: str, hostname: str, mac_address: str ) -> None: """Process a client.""" - made_ip_address = make_ip_address(ip_address) + if (made_ip_address := cached_ip_addresses(ip_address)) is None: + # Ignore invalid addresses + _LOGGER.debug("Ignoring invalid IP Address: %s", ip_address) + return if ( made_ip_address.is_link_local @@ -489,7 +487,7 @@ def _handle_dhcp_packet(packet: Packet) -> None: def _dhcp_options_as_dict( - dhcp_options: Iterable[tuple[str, int | bytes | None]] + dhcp_options: Iterable[tuple[str, int | bytes | None]], ) -> dict[str, str | int | bytes | None]: """Extract data from packet options as a dict.""" return {option[0]: option[1] for option in dhcp_options if len(option) >= 2} diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 3d9a55780457c5..f190f0ab10e551 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,9 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.5.1"] + "requirements": [ + "scapy==2.5.0", + "aiodiscover==1.6.0", + "cached_ipaddress==0.3.0" + ] } diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json index 8ed52cd36321b9..2c30e3db85c3de 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 32f696a04ceadf..786f589bf7b64d 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -1,12 +1,9 @@ """The Discovergy integration.""" 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 from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform @@ -20,35 +17,21 @@ PLATFORMS = [Platform.SENSOR] -@dataclass -class DiscovergyData: - """Discovergy data class to share meters and api client.""" - - api_client: pydiscovergy.Discovergy - meters: list[Meter] - coordinators: dict[str, DiscovergyUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Discovergy from a config entry.""" hass.data.setdefault(DOMAIN, {}) - # init discovergy data class - discovergy_data = DiscovergyData( - api_client=pydiscovergy.Discovergy( - email=entry.data[CONF_EMAIL], - password=entry.data[CONF_PASSWORD], - httpx_client=get_async_client(hass), - authentication=BasicAuth(), - ), - meters=[], - coordinators={}, + client = Discovergy( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + httpx_client=get_async_client(hass), + authentication=BasicAuth(), ) try: # try to get meters from api to check if credentials are still valid and for later use # if no exception is raised everything is fine to go - discovergy_data.meters = await discovergy_data.api_client.meters() + meters = await client.meters() except discovergyError.InvalidLogin as err: raise ConfigEntryAuthFailed("Invalid email or password") from err except Exception as err: @@ -57,19 +40,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from err # Init coordinators for meters - for meter in discovergy_data.meters: + coordinators = [] + for meter in meters: # Create coordinator for meter, set config entry and fetch initial data, # so we have data when entities are added coordinator = DiscovergyUpdateCoordinator( hass=hass, meter=meter, - discovergy_client=discovergy_data.api_client, + discovergy_client=client, ) await coordinator.async_config_entry_first_refresh() + coordinators.append(coordinator) - discovergy_data.coordinators[meter.meter_id] = coordinator - - hass.data[DOMAIN][entry.entry_id] = discovergy_data + hass.data[DOMAIN][entry.entry_id] = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index b3dee2d82a0263..38a250a381db4a 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -5,7 +5,7 @@ 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 @@ async def _validate_and_save( 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 5f27c6a43d2ad1..5a3448a9e4bbae 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -12,17 +12,12 @@ 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 @@ def __init__( 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 e0a9e47e6fde00..99d559e94bc934 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -4,18 +4,15 @@ 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 -from . import DiscovergyData from .const import DOMAIN +from .coordinator import DiscovergyUpdateCoordinator TO_REDACT_METER = { "serial_number", - "full_serial_number", "location", "full_serial_number", "printed_full_serial_number", @@ -29,16 +26,16 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" 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 + coordinators: list[DiscovergyUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id] - for meter in meters: + for coordinator in coordinators: # make a dict of meter data and redact some data - flattened_meter.append(async_redact_data(asdict(meter), TO_REDACT_METER)) + flattened_meter.append( + async_redact_data(asdict(coordinator.meter), TO_REDACT_METER) + ) # get last reading for meter and make a dict of it - coordinator = data.coordinators[meter.meter_id] - last_readings[meter.meter_id] = asdict(coordinator.data) + last_readings[coordinator.meter.meter_id] = asdict(coordinator.data) return { "meters": flattened_meter, diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 0f5ace28dd73b6..00513db484b5d6 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from datetime import datetime -from pydiscovergy.models import Meter, Reading +from pydiscovergy.models import Reading from homeassistant.components.sensor import ( SensorDeviceClass, @@ -24,28 +24,28 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DiscovergyData, DiscovergyUpdateCoordinator from .const import DOMAIN, MANUFACTURER +from .coordinator import DiscovergyUpdateCoordinator -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(frozen=True, 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", @@ -165,38 +165,31 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Discovergy sensors.""" - data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] - meters: list[Meter] = data.meters # always returns a list + coordinators: list[DiscovergyUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id] 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 - - 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) - ) - - for description in ADDITIONAL_SENSORS: - entities.append( - DiscovergySensor(description.key, description, meter, coordinator) - ) + for coordinator in coordinators: + sensors: tuple[DiscovergySensorEntityDescription, ...] = () + + # select sensor descriptions based on meter type and combine with additional sensors + if coordinator.meter.measurement_type == "ELECTRICITY": + sensors = ELECTRICITY_SENSORS + ADDITIONAL_SENSORS + elif coordinator.meter.measurement_type == "GAS": + sensors = GAS_SENSORS + ADDITIONAL_SENSORS + + entities.extend( + DiscovergySensor(value_key, description, coordinator) + for description in sensors + for value_key in {description.key, *description.alternative_keys} + if description.value_fn(coordinator.data, value_key, description.scale) + is not None + ) - 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 @@ -206,15 +199,15 @@ def __init__( self, data_key: str, description: DiscovergySensorEntityDescription, - meter: Meter, coordinator: DiscovergyUpdateCoordinator, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.data_key = data_key - self.entity_description = description + + meter = coordinator.meter self._attr_unique_id = f"{meter.full_serial_number}-{data_key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, meter.meter_id)}, diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index 8c60d59fa6b2f5..9f21a9571e96df 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/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 0fa884319c414a..e2a07a3e351adc 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.36.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.38.0", "getmac==0.9.4"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 3a57ba2c8ced22..749f2c887eb2fd 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -55,7 +55,7 @@ def catch_request_errors( - func: Callable[Concatenate[_DlnaDmrEntityT, _P], Awaitable[_R]] + func: Callable[Concatenate[_DlnaDmrEntityT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_DlnaDmrEntityT, _P], Coroutine[Any, Any, _R | None]]: """Catch UpnpError errors.""" @@ -453,10 +453,9 @@ def _on_event( 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/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 6352d98da3cced..62ff2be7d5b54d 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -124,7 +124,7 @@ class ActionError(DlnaDmsDeviceError): def catch_request_errors( - func: Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]] + func: Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]], ) -> Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]]: """Catch UpnpError errors.""" diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index b3fa91a2e70d2b..6173c9a3843e89 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.36.2"], + "requirements": ["async-upnp-client==0.38.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index ebe5216ab6943c..a4b0d34b339af7 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -23,6 +23,8 @@ DOMAIN, ) +DEFAULT_RETRIES = 2 + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=120) @@ -67,6 +69,7 @@ def __init__( self.resolver = aiodns.DNSResolver() self.resolver.nameservers = [resolver] self.querytype = "AAAA" if ipv6 else "A" + self._retries = DEFAULT_RETRIES self._attr_extra_state_attributes = { "Resolver": resolver, "Querytype": self.querytype, @@ -90,5 +93,8 @@ async def async_update(self) -> None: if response: self._attr_native_value = response[0].host self._attr_available = True + self._retries = DEFAULT_RETRIES + elif self._retries > 0: + self._retries -= 1 else: self._attr_available = False diff --git a/homeassistant/components/doorbird/button.py b/homeassistant/components/doorbird/button.py index 1c69429d3c7087..1e1b4c55e18524 100644 --- a/homeassistant/components/doorbird/button.py +++ b/homeassistant/components/doorbird/button.py @@ -17,14 +17,14 @@ IR_RELAY = "__ir_light__" -@dataclass +@dataclass(frozen=True) class DoorbirdButtonEntityDescriptionMixin: """Mixin to describe a Doorbird Button entity.""" press_action: Callable[[DoorBird, str], None] -@dataclass +@dataclass(frozen=True) class DoorbirdButtonEntityDescription( ButtonEntityDescription, DoorbirdButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index ceaf1a891eedf3..c851de379d4c6b 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/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index 6cfbdd50b34a16..2ec2b0a1c91799 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -22,14 +22,14 @@ from .models import DormakabaDkeyData -@dataclass +@dataclass(frozen=True) class DormakabaDkeyBinarySensorDescriptionMixin: """Class for keys required by Dormakaba dKey binary sensor entity.""" is_on: Callable[[Notifications], bool] -@dataclass +@dataclass(frozen=True) class DormakabaDkeyBinarySensorDescription( BinarySensorEntityDescription, DormakabaDkeyBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index f84261de44b774..4de20bf86e8c4e 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -30,14 +30,14 @@ SENSOR_SMS_UNREAD = "sms" -@dataclass +@dataclass(frozen=True) class DovadoRequiredKeysMixin: """Mixin for required keys.""" identifier: str -@dataclass +@dataclass(frozen=True) class DovadoSensorEntityDescription(SensorEntityDescription, DovadoRequiredKeysMixin): """Describes Dovado sensor entity.""" diff --git a/homeassistant/components/dremel_3d_printer/binary_sensor.py b/homeassistant/components/dremel_3d_printer/binary_sensor.py index 3a92bfe5510be4..22c2a1a9557f83 100644 --- a/homeassistant/components/dremel_3d_printer/binary_sensor.py +++ b/homeassistant/components/dremel_3d_printer/binary_sensor.py @@ -19,14 +19,14 @@ from .entity import Dremel3DPrinterEntity -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterBinarySensorEntityMixin: """Mixin for Dremel 3D Printer binary sensor.""" value_fn: Callable[[Dremel3DPrinter], bool] -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterBinarySensorEntityDescription( BinarySensorEntityDescription, Dremel3DPrinterBinarySensorEntityMixin ): diff --git a/homeassistant/components/dremel_3d_printer/button.py b/homeassistant/components/dremel_3d_printer/button.py index 2d328b30ceaed9..b2ea103f78b750 100644 --- a/homeassistant/components/dremel_3d_printer/button.py +++ b/homeassistant/components/dremel_3d_printer/button.py @@ -16,14 +16,14 @@ from .entity import Dremel3DPrinterEntity -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterButtonEntityMixin: """Mixin for required keys.""" press_fn: Callable[[Dremel3DPrinter], None] -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterButtonEntityDescription( ButtonEntityDescription, Dremel3DPrinterButtonEntityMixin ): diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py index 660e7a90487b8c..b24b01d230856c 100644 --- a/homeassistant/components/dremel_3d_printer/sensor.py +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -31,14 +31,14 @@ from .entity import Dremel3DPrinterEntity -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterSensorEntityMixin: """Mixin for Dremel 3D Printer sensor.""" value_fn: Callable[[Dremel3DPrinter, str], StateType | datetime] -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterSensorEntityDescription( SensorEntityDescription, Dremel3DPrinterSensorEntityMixin ): diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json index 0016b8f2bca144..9f6870b57f6f1b 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/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py new file mode 100644 index 00000000000000..7bfab762f99271 --- /dev/null +++ b/homeassistant/components/drop_connect/__init__.py @@ -0,0 +1,71 @@ +"""The drop_connect integration.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from homeassistant.components import mqtt +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback + +from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE, DOMAIN +from .coordinator import DROPDeviceDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up DROP from a config entry.""" + + # Make sure MQTT integration is enabled and the client is available. + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + + if TYPE_CHECKING: + assert config_entry.unique_id is not None + drop_data_coordinator = DROPDeviceDataUpdateCoordinator( + hass, config_entry.unique_id + ) + + @callback + def mqtt_callback(msg: ReceiveMessage) -> None: + """Pass MQTT payload to DROP API parser.""" + if drop_data_coordinator.drop_api.parse_drop_message( + msg.topic, msg.payload, msg.qos, msg.retain + ): + drop_data_coordinator.async_set_updated_data(None) + + config_entry.async_on_unload( + await mqtt.async_subscribe( + hass, config_entry.data[CONF_DATA_TOPIC], mqtt_callback + ) + ) + _LOGGER.debug( + "Entry %s (%s) subscribed to %s", + config_entry.unique_id, + config_entry.data[CONF_DEVICE_TYPE], + config_entry.data[CONF_DATA_TOPIC], + ) + + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = drop_data_coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + hass.data[DOMAIN].pop(config_entry.entry_id) + return unload_ok diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py new file mode 100644 index 00000000000000..1bce60f87b357e --- /dev/null +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -0,0 +1,138 @@ +"""Support for DROP binary sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +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 ( + CONF_DEVICE_TYPE, + DEV_HUB, + DEV_LEAK_DETECTOR, + DEV_PROTECTION_VALVE, + DEV_PUMP_CONTROLLER, + DEV_RO_FILTER, + DEV_SALT_SENSOR, + DEV_SOFTENER, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +LEAK_ICON = "mdi:pipe-leak" +NOTIFICATION_ICON = "mdi:bell-ring" +PUMP_ICON = "mdi:water-pump" +SALT_ICON = "mdi:shaker" +WATER_ICON = "mdi:water" + +# Binary sensor type constants +LEAK_DETECTED = "leak" +PENDING_NOTIFICATION = "pending_notification" +PUMP_STATUS = "pump" +RESERVE_IN_USE = "reserve_in_use" +SALT_LOW = "salt" + + +@dataclass(kw_only=True, frozen=True) +class DROPBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes DROP binary sensor entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], int | None] + + +BINARY_SENSORS: list[DROPBinarySensorEntityDescription] = [ + DROPBinarySensorEntityDescription( + key=LEAK_DETECTED, + translation_key=LEAK_DETECTED, + icon=LEAK_ICON, + device_class=BinarySensorDeviceClass.MOISTURE, + value_fn=lambda device: device.drop_api.leak_detected(), + ), + DROPBinarySensorEntityDescription( + key=PENDING_NOTIFICATION, + translation_key=PENDING_NOTIFICATION, + icon=NOTIFICATION_ICON, + value_fn=lambda device: device.drop_api.notification_pending(), + ), + DROPBinarySensorEntityDescription( + key=SALT_LOW, + translation_key=SALT_LOW, + icon=SALT_ICON, + value_fn=lambda device: device.drop_api.salt_low(), + ), + DROPBinarySensorEntityDescription( + key=RESERVE_IN_USE, + translation_key=RESERVE_IN_USE, + icon=WATER_ICON, + value_fn=lambda device: device.drop_api.reserve_in_use(), + ), + DROPBinarySensorEntityDescription( + key=PUMP_STATUS, + translation_key=PUMP_STATUS, + icon=PUMP_ICON, + value_fn=lambda device: device.drop_api.pump_status(), + ), +] + +# Defines which binary sensors are used by each device type +DEVICE_BINARY_SENSORS: dict[str, list[str]] = { + DEV_HUB: [LEAK_DETECTED, PENDING_NOTIFICATION], + DEV_LEAK_DETECTOR: [LEAK_DETECTED], + DEV_PROTECTION_VALVE: [LEAK_DETECTED], + DEV_PUMP_CONTROLLER: [LEAK_DETECTED, PUMP_STATUS], + DEV_RO_FILTER: [LEAK_DETECTED], + DEV_SALT_SENSOR: [SALT_LOW], + DEV_SOFTENER: [RESERVE_IN_USE], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP binary sensors from config entry.""" + _LOGGER.debug( + "Set up binary sensor for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_BINARY_SENSORS: + async_add_entities( + DROPBinarySensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + for sensor in BINARY_SENSORS + if sensor.key in DEVICE_BINARY_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPBinarySensor(DROPEntity, BinarySensorEntity): + """Representation of a DROP binary sensor.""" + + entity_description: DROPBinarySensorEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.value_fn(self.coordinator) == 1 diff --git a/homeassistant/components/drop_connect/config_flow.py b/homeassistant/components/drop_connect/config_flow.py new file mode 100644 index 00000000000000..a2b93ad1da1290 --- /dev/null +++ b/homeassistant/components/drop_connect/config_flow.py @@ -0,0 +1,98 @@ +"""Config flow for drop_connect integration.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from dropmqttapi.discovery import DropDiscovery + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import ( + CONF_COMMAND_TOPIC, + CONF_DATA_TOPIC, + CONF_DEVICE_DESC, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_DEVICE_OWNER_ID, + CONF_DEVICE_TYPE, + CONF_HUB_ID, + DISCOVERY_TOPIC, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle DROP config flow.""" + + VERSION = 1 + + _drop_discovery: DropDiscovery | None = None + + async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: + """Handle a flow initialized by MQTT discovery.""" + + # Abort if the topic does not match our discovery topic or the payload is empty. + if ( + discovery_info.subscribed_topic != DISCOVERY_TOPIC + or not discovery_info.payload + ): + return self.async_abort(reason="invalid_discovery_info") + + self._drop_discovery = DropDiscovery(DOMAIN) + if not ( + await self._drop_discovery.parse_discovery( + discovery_info.topic, discovery_info.payload + ) + ): + return self.async_abort(reason="invalid_discovery_info") + existing_entry = await self.async_set_unique_id( + f"{self._drop_discovery.hub_id}_{self._drop_discovery.device_id}" + ) + if existing_entry is not None: + # Note: returning "invalid_discovery_info" here instead of "already_configured" + # allows discovery of additional device types. + return self.async_abort(reason="invalid_discovery_info") + + self.context.update({"title_placeholders": {"name": self._drop_discovery.name}}) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm the setup.""" + if TYPE_CHECKING: + assert self._drop_discovery is not None + if user_input is not None: + device_data = { + CONF_COMMAND_TOPIC: self._drop_discovery.command_topic, + CONF_DATA_TOPIC: self._drop_discovery.data_topic, + CONF_DEVICE_DESC: self._drop_discovery.device_desc, + CONF_DEVICE_ID: self._drop_discovery.device_id, + CONF_DEVICE_NAME: self._drop_discovery.name, + CONF_DEVICE_TYPE: self._drop_discovery.device_type, + CONF_HUB_ID: self._drop_discovery.hub_id, + CONF_DEVICE_OWNER_ID: self._drop_discovery.owner_id, + } + return self.async_create_entry( + title=self._drop_discovery.name, data=device_data + ) + + return self.async_show_form( + step_id="confirm", + description_placeholders={ + "device_name": self._drop_discovery.name, + "device_type": self._drop_discovery.device_desc, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + return self.async_abort(reason="not_supported") diff --git a/homeassistant/components/drop_connect/const.py b/homeassistant/components/drop_connect/const.py new file mode 100644 index 00000000000000..38a8a57ea722eb --- /dev/null +++ b/homeassistant/components/drop_connect/const.py @@ -0,0 +1,25 @@ +"""Constants for the drop_connect integration.""" + +# Keys for values used in the config_entry data dictionary +CONF_COMMAND_TOPIC = "drop_command_topic" +CONF_DATA_TOPIC = "drop_data_topic" +CONF_DEVICE_DESC = "device_desc" +CONF_DEVICE_ID = "device_id" +CONF_DEVICE_TYPE = "device_type" +CONF_HUB_ID = "drop_hub_id" +CONF_DEVICE_NAME = "name" +CONF_DEVICE_OWNER_ID = "drop_device_owner_id" + +# Values for DROP device types +DEV_FILTER = "filt" +DEV_HUB = "hub" +DEV_LEAK_DETECTOR = "leak" +DEV_PROTECTION_VALVE = "pv" +DEV_PUMP_CONTROLLER = "pc" +DEV_RO_FILTER = "ro" +DEV_SALT_SENSOR = "salt" +DEV_SOFTENER = "soft" + +DISCOVERY_TOPIC = "drop_connect/discovery/#" + +DOMAIN = "drop_connect" diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py new file mode 100644 index 00000000000000..e4937ed5f65676 --- /dev/null +++ b/homeassistant/components/drop_connect/coordinator.py @@ -0,0 +1,53 @@ +"""DROP device data update coordinator object.""" +from __future__ import annotations + +import logging + +from dropmqttapi.mqttapi import DropAPI + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_COMMAND_TOPIC, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): + """DROP device object.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, unique_id: str) -> None: + """Initialize the device.""" + super().__init__(hass, _LOGGER, name=f"{DOMAIN}-{unique_id}") + self.drop_api = DropAPI() + + async def set_water(self, value: int) -> None: + """Change water supply state.""" + payload = self.drop_api.set_water_message(value) + await mqtt.async_publish( + self.hass, + self.config_entry.data[CONF_COMMAND_TOPIC], + payload, + ) + + async def set_bypass(self, value: int) -> None: + """Change water bypass state.""" + payload = self.drop_api.set_bypass_message(value) + await mqtt.async_publish( + self.hass, + self.config_entry.data[CONF_COMMAND_TOPIC], + payload, + ) + + async def set_protect_mode(self, value: str) -> None: + """Change protect mode state.""" + payload = self.drop_api.set_protect_mode_message(value) + await mqtt.async_publish( + self.hass, + self.config_entry.data[CONF_COMMAND_TOPIC], + payload, + ) diff --git a/homeassistant/components/drop_connect/entity.py b/homeassistant/components/drop_connect/entity.py new file mode 100644 index 00000000000000..85c506b19a37a0 --- /dev/null +++ b/homeassistant/components/drop_connect/entity.py @@ -0,0 +1,53 @@ +"""Base entity class for DROP entities.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_DEVICE_DESC, + CONF_DEVICE_NAME, + CONF_DEVICE_OWNER_ID, + CONF_DEVICE_TYPE, + CONF_HUB_ID, + DEV_HUB, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator + + +class DROPEntity(CoordinatorEntity[DROPDeviceDataUpdateCoordinator]): + """Representation of a DROP device entity.""" + + _attr_has_entity_name = True + + def __init__( + self, entity_type: str, coordinator: DROPDeviceDataUpdateCoordinator + ) -> None: + """Init DROP entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id is not None + unique_id = coordinator.config_entry.unique_id + self._attr_unique_id = f"{unique_id}_{entity_type}" + entry_data = coordinator.config_entry.data + model: str = entry_data[CONF_DEVICE_DESC] + if entry_data[CONF_DEVICE_TYPE] == DEV_HUB: + model = f"Hub {entry_data[CONF_HUB_ID]}" + self._attr_device_info = DeviceInfo( + manufacturer="Chandler Systems, Inc.", + model=model, + name=entry_data[CONF_DEVICE_NAME], + identifiers={(DOMAIN, unique_id)}, + ) + if entry_data[CONF_DEVICE_TYPE] != DEV_HUB: + self._attr_device_info.update( + { + "via_device": ( + DOMAIN, + entry_data[CONF_DEVICE_OWNER_ID], + ) + } + ) diff --git a/homeassistant/components/drop_connect/manifest.json b/homeassistant/components/drop_connect/manifest.json new file mode 100644 index 00000000000000..5df34fce561c99 --- /dev/null +++ b/homeassistant/components/drop_connect/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "drop_connect", + "name": "DROP", + "codeowners": ["@ChandlerSystems", "@pfrazer"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/drop_connect", + "iot_class": "local_push", + "mqtt": ["drop_connect/discovery/#"], + "requirements": ["dropmqttapi==1.0.2"] +} diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py new file mode 100644 index 00000000000000..e026cfcd59e73d --- /dev/null +++ b/homeassistant/components/drop_connect/select.py @@ -0,0 +1,96 @@ +"""Support for DROP selects.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging +from typing import Any + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_DEVICE_TYPE, DEV_HUB, DOMAIN +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +# Select type constants +PROTECT_MODE = "protect_mode" + +PROTECT_MODE_OPTIONS = ["away", "home", "schedule"] + +FLOOD_ICON = "mdi:home-flood" + + +@dataclass(kw_only=True, frozen=True) +class DROPSelectEntityDescription(SelectEntityDescription): + """Describes DROP select entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], int | None] + set_fn: Callable[[DROPDeviceDataUpdateCoordinator, str], Awaitable[Any]] + + +SELECTS: list[DROPSelectEntityDescription] = [ + DROPSelectEntityDescription( + key=PROTECT_MODE, + translation_key=PROTECT_MODE, + icon=FLOOD_ICON, + options=PROTECT_MODE_OPTIONS, + value_fn=lambda device: device.drop_api.protect_mode(), + set_fn=lambda device, value: device.set_protect_mode(value), + ) +] + +# Defines which selects are used by each device type +DEVICE_SELECTS: dict[str, list[str]] = { + DEV_HUB: [PROTECT_MODE], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP selects from config entry.""" + _LOGGER.debug( + "Set up select for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SELECTS: + async_add_entities( + DROPSelect(hass.data[DOMAIN][config_entry.entry_id], select) + for select in SELECTS + if select.key in DEVICE_SELECTS[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPSelect(DROPEntity, SelectEntity): + """Representation of a DROP select.""" + + entity_description: DROPSelectEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPSelectEntityDescription, + ) -> None: + """Initialize the select.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + val = self.entity_description.value_fn(self.coordinator) + return str(val) if val else None + + async def async_select_option(self, option: str) -> None: + """Update the current selected option.""" + await self.entity_description.set_fn(self.coordinator, option) diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py new file mode 100644 index 00000000000000..c5215df8395ff3 --- /dev/null +++ b/homeassistant/components/drop_connect/sensor.py @@ -0,0 +1,285 @@ +"""Support for DROP sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + EntityCategory, + UnitOfPressure, + UnitOfTemperature, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_DEVICE_TYPE, + DEV_FILTER, + DEV_HUB, + DEV_LEAK_DETECTOR, + DEV_PROTECTION_VALVE, + DEV_PUMP_CONTROLLER, + DEV_RO_FILTER, + DEV_SOFTENER, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +FLOW_ICON = "mdi:shower-head" +GAUGE_ICON = "mdi:gauge" +TDS_ICON = "mdi:water-opacity" + +# Sensor type constants +CURRENT_FLOW_RATE = "current_flow_rate" +PEAK_FLOW_RATE = "peak_flow_rate" +WATER_USED_TODAY = "water_used_today" +AVERAGE_WATER_USED = "average_water_used" +CAPACITY_REMAINING = "capacity_remaining" +CURRENT_SYSTEM_PRESSURE = "current_system_pressure" +HIGH_SYSTEM_PRESSURE = "high_system_pressure" +LOW_SYSTEM_PRESSURE = "low_system_pressure" +BATTERY = "battery" +TEMPERATURE = "temperature" +INLET_TDS = "inlet_tds" +OUTLET_TDS = "outlet_tds" +CARTRIDGE_1_LIFE = "cart1" +CARTRIDGE_2_LIFE = "cart2" +CARTRIDGE_3_LIFE = "cart3" + + +@dataclass(kw_only=True, frozen=True) +class DROPSensorEntityDescription(SensorEntityDescription): + """Describes DROP sensor entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], float | int | None] + + +SENSORS: list[DROPSensorEntityDescription] = [ + DROPSensorEntityDescription( + key=CURRENT_FLOW_RATE, + translation_key=CURRENT_FLOW_RATE, + icon="mdi:shower-head", + native_unit_of_measurement="gpm", + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.current_flow_rate(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=PEAK_FLOW_RATE, + translation_key=PEAK_FLOW_RATE, + icon="mdi:shower-head", + native_unit_of_measurement="gpm", + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.peak_flow_rate(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=WATER_USED_TODAY, + translation_key=WATER_USED_TODAY, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.water_used_today(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=AVERAGE_WATER_USED, + translation_key=AVERAGE_WATER_USED, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.average_water_used(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=CAPACITY_REMAINING, + translation_key=CAPACITY_REMAINING, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.capacity_remaining(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=CURRENT_SYSTEM_PRESSURE, + translation_key=CURRENT_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.current_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=HIGH_SYSTEM_PRESSURE, + translation_key=HIGH_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.high_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=LOW_SYSTEM_PRESSURE, + translation_key=LOW_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.low_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=BATTERY, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.battery(), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DROPSensorEntityDescription( + key=TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.temperature(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=INLET_TDS, + translation_key=INLET_TDS, + icon=TDS_ICON, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.inlet_tds(), + ), + DROPSensorEntityDescription( + key=OUTLET_TDS, + translation_key=OUTLET_TDS, + icon=TDS_ICON, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.outlet_tds(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_1_LIFE, + translation_key=CARTRIDGE_1_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart1(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_2_LIFE, + translation_key=CARTRIDGE_2_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart2(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_3_LIFE, + translation_key=CARTRIDGE_3_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart3(), + ), +] + +# Defines which sensors are used by each device type +DEVICE_SENSORS: dict[str, list[str]] = { + DEV_HUB: [ + AVERAGE_WATER_USED, + BATTERY, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + HIGH_SYSTEM_PRESSURE, + LOW_SYSTEM_PRESSURE, + PEAK_FLOW_RATE, + WATER_USED_TODAY, + ], + DEV_SOFTENER: [ + BATTERY, + CAPACITY_REMAINING, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + ], + DEV_FILTER: [BATTERY, CURRENT_FLOW_RATE, CURRENT_SYSTEM_PRESSURE], + DEV_LEAK_DETECTOR: [BATTERY, TEMPERATURE], + DEV_PROTECTION_VALVE: [ + BATTERY, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + TEMPERATURE, + ], + DEV_PUMP_CONTROLLER: [CURRENT_FLOW_RATE, CURRENT_SYSTEM_PRESSURE, TEMPERATURE], + DEV_RO_FILTER: [ + CARTRIDGE_1_LIFE, + CARTRIDGE_2_LIFE, + CARTRIDGE_3_LIFE, + INLET_TDS, + OUTLET_TDS, + ], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP sensors from config entry.""" + _LOGGER.debug( + "Set up sensor for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SENSORS: + async_add_entities( + DROPSensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + for sensor in SENSORS + if sensor.key in DEVICE_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPSensor(DROPEntity, SensorEntity): + """Representation of a DROP sensor.""" + + entity_description: DROPSensorEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def native_value(self) -> float | int | None: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json new file mode 100644 index 00000000000000..761d134bd184f5 --- /dev/null +++ b/homeassistant/components/drop_connect/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "abort": { + "not_supported": "Configuration for DROP is through MQTT discovery. Use the DROP Connect app to connect your DROP Hub to your MQTT broker." + }, + "step": { + "confirm": { + "title": "Confirm association", + "description": "Do you want to configure the DROP {device_type} named {device_name}?'" + } + } + }, + "entity": { + "sensor": { + "current_flow_rate": { "name": "Water flow rate" }, + "peak_flow_rate": { "name": "Peak water flow rate today" }, + "water_used_today": { "name": "Total water used today" }, + "average_water_used": { "name": "Average daily water usage" }, + "capacity_remaining": { "name": "Capacity remaining" }, + "current_system_pressure": { "name": "Current water pressure" }, + "high_system_pressure": { "name": "High water pressure today" }, + "low_system_pressure": { "name": "Low water pressure today" }, + "inlet_tds": { "name": "Inlet TDS" }, + "outlet_tds": { "name": "Outlet TDS" }, + "cart1": { "name": "Cartridge 1 life remaining" }, + "cart2": { "name": "Cartridge 2 life remaining" }, + "cart3": { "name": "Cartridge 3 life remaining" } + }, + "binary_sensor": { + "leak": { "name": "Leak detected" }, + "pending_notification": { "name": "Notification unread" }, + "reserve_in_use": { "name": "Reserve capacity in use" }, + "salt": { "name": "Salt low" }, + "pump": { "name": "Pump status" } + }, + "select": { + "protect_mode": { + "name": "Protect mode", + "state": { + "away": "Away", + "home": "Home", + "schedule": "Schedule" + } + } + }, + "switch": { + "water": { "name": "Water supply" }, + "bypass": { "name": "Treatment bypass" } + } + } +} diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py new file mode 100644 index 00000000000000..b0ebe4b5a8592e --- /dev/null +++ b/homeassistant/components/drop_connect/switch.py @@ -0,0 +1,124 @@ +"""Support for DROP switches.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_DEVICE_TYPE, + DEV_FILTER, + DEV_HUB, + DEV_PROTECTION_VALVE, + DEV_SOFTENER, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +ICON_VALVE_OPEN = "mdi:valve-open" +ICON_VALVE_CLOSED = "mdi:valve-closed" +ICON_VALVE_UNKNOWN = "mdi:valve" +ICON_VALVE = {False: ICON_VALVE_CLOSED, True: ICON_VALVE_OPEN, None: ICON_VALVE_UNKNOWN} + +SWITCH_VALUE: dict[int | None, bool] = {0: False, 1: True} + +# Switch type constants +WATER_SWITCH = "water" +BYPASS_SWITCH = "bypass" + + +@dataclass(kw_only=True, frozen=True) +class DROPSwitchEntityDescription(SwitchEntityDescription): + """Describes DROP switch entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], int | None] + set_fn: Callable[[DROPDeviceDataUpdateCoordinator, int], Awaitable[Any]] + + +SWITCHES: list[DROPSwitchEntityDescription] = [ + DROPSwitchEntityDescription( + key=WATER_SWITCH, + translation_key=WATER_SWITCH, + icon=ICON_VALVE_UNKNOWN, + value_fn=lambda device: device.drop_api.water(), + set_fn=lambda device, value: device.set_water(value), + ), + DROPSwitchEntityDescription( + key=BYPASS_SWITCH, + translation_key=BYPASS_SWITCH, + icon=ICON_VALVE_UNKNOWN, + value_fn=lambda device: device.drop_api.bypass(), + set_fn=lambda device, value: device.set_bypass(value), + ), +] + +# Defines which switches are used by each device type +DEVICE_SWITCHES: dict[str, list[str]] = { + DEV_FILTER: [BYPASS_SWITCH], + DEV_HUB: [WATER_SWITCH, BYPASS_SWITCH], + DEV_PROTECTION_VALVE: [WATER_SWITCH], + DEV_SOFTENER: [BYPASS_SWITCH], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP switches from config entry.""" + _LOGGER.debug( + "Set up switch for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SWITCHES: + async_add_entities( + DROPSwitch(hass.data[DOMAIN][config_entry.entry_id], switch) + for switch in SWITCHES + if switch.key in DEVICE_SWITCHES[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPSwitch(DROPEntity, SwitchEntity): + """Representation of a DROP switch.""" + + entity_description: DROPSwitchEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return SWITCH_VALUE.get(self.entity_description.value_fn(self.coordinator)) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + await self.entity_description.set_fn(self.coordinator, 1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + await self.entity_description.set_fn(self.coordinator, 0) + + @property + def icon(self) -> str: + """Return the icon to use for dynamic states.""" + return ICON_VALVE[self.is_on] diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 3b32d354766345..376b4d100fc97c 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -19,13 +19,12 @@ from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_DSMR_VERSION, - CONF_PROTOCOL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, @@ -116,7 +115,7 @@ def update_telegram(telegram: dict[str, DSMRObject]) -> None: try: transport, protocol = await asyncio.create_task(reader_factory()) - except (serial.serialutil.SerialException, OSError): + except (serial.SerialException, OSError): LOGGER.exception("Error connecting to DSMR") return False diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 5e1a54aedc4fa7..9504929c5a9a3d 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -11,9 +11,6 @@ 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" CONF_SERIAL_ID = "serial_id" @@ -29,6 +26,7 @@ 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 fa58bd8c5a6d19..3e26ee1ea62db0 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -28,12 +28,14 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, + CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP, EntityCategory, UnitOfEnergy, 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, @@ -45,9 +47,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 +56,7 @@ DEFAULT_TIME_BETWEEN_UPDATE, DEVICE_NAME_ELECTRICITY, DEVICE_NAME_GAS, + DEVICE_NAME_WATER, DOMAIN, DSMR_PROTOCOL, LOGGER, @@ -67,30 +67,29 @@ UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS} -@dataclass -class DSMRSensorEntityDescriptionMixin: - """Mixin for required keys.""" - - obis_reference: str - - -@dataclass -class DSMRSensorEntityDescription( - SensorEntityDescription, DSMRSensorEntityDescriptionMixin -): +@dataclass(frozen=True, 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, ...] = ( + DSMRSensorEntityDescription( + key="timestamp", + obis_reference=obis_references.P1_MESSAGE_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), DSMRSensorEntityDescription( key="current_electricity_usage", 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 +97,6 @@ class 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 +114,6 @@ class 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 +121,6 @@ class 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 +129,6 @@ class 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 +137,6 @@ class 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 +336,6 @@ class 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 +344,6 @@ class 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 +352,6 @@ class 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 +359,6 @@ class 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 +367,6 @@ class 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 +376,144 @@ class 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 +533,10 @@ def init_async_add_entities(telegram: dict[str, DSMRObject]) -> None: 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 +544,9 @@ def device_class_and_uom( 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,11 +652,9 @@ def close_transport(_event: Event) -> None: 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): + except (serial.SerialException, OSError): # Log any error while establishing connection and drop to retry # connection wait LOGGER.exception("Error connecting to DSMR") @@ -588,9 +666,7 @@ def close_transport(_event: Event) -> None: 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 +717,8 @@ def __init__( 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 +730,15 @@ def __init__( 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 +746,13 @@ def __init__( 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: @@ -705,9 +796,11 @@ def native_value(self) -> StateType: return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION]) with suppress(TypeError): - value = round( - float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) - ) + value = round(float(value), 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 diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 5f0568e290592a..055c0c412648ff 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/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index f12b2ad72bc70d..2b5b995eabde0c 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -38,7 +38,7 @@ def tariff_transform(value): return "high" -@dataclass +@dataclass(frozen=True) class DSMRReaderSensorEntityDescription(SensorEntityDescription): """Sensor entity description for DSMR Reader.""" diff --git a/homeassistant/components/dunehd/strings.json b/homeassistant/components/dunehd/strings.json index f7e12b39f169bd..7d60a720a98c3c 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 8e23e742c0409f..dc10e0a61d9d26 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -52,7 +52,7 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): _attr_translation_key = "duotecno" @property - def current_temperature(self) -> int | None: + def current_temperature(self) -> float | None: """Get the current temperature.""" return self._unit.get_cur_temp() diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py index d38d52a0d2691c..8d905979bfeec4 100644 --- a/homeassistant/components/duotecno/entity.py +++ b/homeassistant/components/duotecno/entity.py @@ -47,7 +47,7 @@ async def _on_update(self) -> None: def api_call( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 2f2219291784ae..9f6d082cae8561 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "quality_scale": "silver", - "requirements": ["pyDuotecno==2023.11.1"] + "requirements": ["pyDuotecno==2024.1.1"] } diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index 93a545d31dc1f2..a5585c3dd2c890 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/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index 4abc02c05659ff..25d18dd92e8928 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -138,7 +138,7 @@ def convert_template(config: dict[str, Any]) -> dict[str, Any]: def convert_config( - config: dict[str, Any] | MappingProxyType[str, Any] + config: dict[str, Any] | MappingProxyType[str, Any], ) -> dict[str, Any]: """Convert a config dict by replacing component consts with library consts.""" my_map = { diff --git a/homeassistant/components/easyenergy/__init__.py b/homeassistant/components/easyenergy/__init__.py index 498a355f0abba8..e941c78b1fb316 100644 --- a/homeassistant/components/easyenergy/__init__.py +++ b/homeassistant/components/easyenergy/__init__.py @@ -5,11 +5,23 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import EasyEnergyDataUpdateCoordinator +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the easyEnergy services.""" + + async_setup_services(hass) + + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -25,6 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 5755a1b3dbe5f8..6f57ea6ed5fab0 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==2.1.0"] } diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 28bcbbafcb81fa..7298c49660f613 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -29,7 +29,7 @@ from .coordinator import EasyEnergyData, EasyEnergyDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class EasyEnergySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -37,7 +37,7 @@ class EasyEnergySensorEntityDescriptionMixin: service_type: str -@dataclass +@dataclass(frozen=True) class EasyEnergySensorEntityDescription( SensorEntityDescription, EasyEnergySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py new file mode 100644 index 00000000000000..a68dfcb791c5f9 --- /dev/null +++ b/homeassistant/components/easyenergy/services.py @@ -0,0 +1,177 @@ +"""Services for easyEnergy integration.""" +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum +from functools import partial +from typing import Final + +from easyenergy import Electricity, Gas, VatOption +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import selector +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import EasyEnergyDataUpdateCoordinator + +ATTR_CONFIG_ENTRY: Final = "config_entry" +ATTR_START: Final = "start" +ATTR_END: Final = "end" +ATTR_INCL_VAT: Final = "incl_vat" + +GAS_SERVICE_NAME: Final = "get_gas_prices" +ENERGY_USAGE_SERVICE_NAME: Final = "get_energy_usage_prices" +ENERGY_RETURN_SERVICE_NAME: Final = "get_energy_return_prices" +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(ATTR_INCL_VAT): bool, + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, + } +) + + +class PriceType(str, Enum): + """Type of price.""" + + ENERGY_USAGE = "energy_usage" + ENERGY_RETURN = "energy_return" + GAS = "gas" + + +def __get_date(date_input: str | None) -> date | datetime: + """Get date.""" + if not date_input: + return dt_util.now().date() + + if value := dt_util.parse_datetime(date_input): + return value + + raise ServiceValidationError( + "Invalid datetime provided.", + translation_domain=DOMAIN, + translation_key="invalid_date", + translation_placeholders={ + "date": date_input, + }, + ) + + +def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResponse: + """Serialize prices to service response.""" + return { + "prices": [ + { + key: str(value) if isinstance(value, datetime) else value + for key, value in timestamp_price.items() + } + for timestamp_price in prices + ] + } + + +def __get_coordinator( + hass: HomeAssistant, call: ServiceCall +) -> EasyEnergyDataUpdateCoordinator: + """Get the coordinator from the entry.""" + entry_id: str = call.data[ATTR_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + + if not entry: + raise ServiceValidationError( + f"Invalid config entry: {entry_id}", + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry": entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + f"{entry.title} is not loaded", + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry": entry.title, + }, + ) + + return hass.data[DOMAIN][entry_id] + + +async def __get_prices( + call: ServiceCall, + *, + hass: HomeAssistant, + price_type: PriceType, +) -> ServiceResponse: + """Get prices from easyEnergy.""" + coordinator = __get_coordinator(hass, call) + + start = __get_date(call.data.get(ATTR_START)) + end = __get_date(call.data.get(ATTR_END)) + + vat = VatOption.INCLUDE + if call.data.get(ATTR_INCL_VAT) is False: + vat = VatOption.EXCLUDE + + data: Electricity | Gas + + if price_type == PriceType.GAS: + data = await coordinator.easyenergy.gas_prices( + start_date=start, + end_date=end, + vat=vat, + ) + return __serialize_prices(data.timestamp_prices) + data = await coordinator.easyenergy.energy_prices( + start_date=start, + end_date=end, + vat=vat, + ) + + if price_type == PriceType.ENERGY_USAGE: + return __serialize_prices(data.timestamp_usage_prices) + return __serialize_prices(data.timestamp_return_prices) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for easyEnergy integration.""" + + hass.services.async_register( + DOMAIN, + GAS_SERVICE_NAME, + partial(__get_prices, hass=hass, price_type=PriceType.GAS), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + ENERGY_USAGE_SERVICE_NAME, + partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_USAGE), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + ENERGY_RETURN_SERVICE_NAME, + partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_RETURN), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/easyenergy/services.yaml b/homeassistant/components/easyenergy/services.yaml new file mode 100644 index 00000000000000..63187256f00d73 --- /dev/null +++ b/homeassistant/components/easyenergy/services.yaml @@ -0,0 +1,61 @@ +get_gas_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: easyenergy + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: +get_energy_usage_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: easyenergy + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: +get_energy_return_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: easyenergy + start: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json index 93fb264b01d4c3..c42ef9df5ac7d7 100644 --- a/homeassistant/components/easyenergy/strings.json +++ b/homeassistant/components/easyenergy/strings.json @@ -9,6 +9,17 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "exceptions": { + "invalid_date": { + "message": "Invalid date provided. Got {date}" + }, + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + }, + "unloaded_config_entry": { + "message": "Invalid config entry provided. {config_entry} is not loaded." + } + }, "entity": { "sensor": { "current_hour_price": { @@ -42,5 +53,69 @@ "name": "Hours priced equal or higher than current - today" } } + }, + "services": { + "get_gas_prices": { + "name": "Get gas prices", + "description": "Request gas prices from easyEnergy.", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, + "incl_vat": { + "name": "VAT Included", + "description": "Include or exclude VAT in the prices, default is true." + }, + "start": { + "name": "Start", + "description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted." + }, + "end": { + "name": "End", + "description": "Specifies the date and time until which to retrieve prices. Defaults to today if omitted." + } + } + }, + "get_energy_usage_prices": { + "name": "Get energy usage prices", + "description": "Request usage energy prices from easyEnergy.", + "fields": { + "config_entry": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::description%]" + }, + "incl_vat": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::description%]" + }, + "start": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]" + }, + "end": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]" + } + } + }, + "get_energy_return_prices": { + "name": "Get energy return prices", + "description": "Request return energy prices from easyEnergy.", + "fields": { + "config_entry": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::description%]" + }, + "start": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]" + }, + "end": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]" + } + } + } } } diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index e1253b585acee8..1b0e65f7390c8f 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -99,6 +99,7 @@ "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 ffb7fe8adfe45c..1160cd946d9b65 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": { diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index 67c975010ab9af..345ca7b705f341 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class EcobeeNumberEntityDescriptionBase: """Required values when describing Ecobee number entities.""" @@ -26,7 +26,7 @@ class EcobeeNumberEntityDescriptionBase: set_fn: Callable[[EcobeeData, int, int], Awaitable] -@dataclass +@dataclass(frozen=True) class EcobeeNumberEntityDescription( NumberEntityDescription, EcobeeNumberEntityDescriptionBase ): diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 4d07ec9447efa3..7f0e7b808a8f0d 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -25,14 +25,14 @@ from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER -@dataclass +@dataclass(frozen=True) class EcobeeSensorEntityDescriptionMixin: """Represent the required ecobee entity description attributes.""" runtime_key: str | None -@dataclass +@dataclass(frozen=True) class EcobeeSensorEntityDescription( SensorEntityDescription, EcobeeSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py index 90ea0bd4dff692..79d62b6a2d261e 100644 --- a/homeassistant/components/ecoforest/number.py +++ b/homeassistant/components/ecoforest/number.py @@ -16,14 +16,14 @@ from .entity import EcoforestEntity -@dataclass +@dataclass(frozen=True) class EcoforestRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Device], float | None] -@dataclass +@dataclass(frozen=True) class EcoforestNumberEntityDescription( NumberEntityDescription, EcoforestRequiredKeysMixin ): diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index e595ddb65f7f55..6f903bee2babb5 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -33,14 +33,14 @@ ALARM_TYPE = [a.value for a in Alarm] + ["none"] -@dataclass +@dataclass(frozen=True) class EcoforestRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Device], StateType] -@dataclass +@dataclass(frozen=True) class EcoforestSensorEntityDescription( SensorEntityDescription, EcoforestRequiredKeysMixin ): diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index d1767be5cdaa45..1094e10ada3c4d 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." } } }, diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py index 32341ff5d616aa..1e70068cde8df8 100644 --- a/homeassistant/components/ecoforest/switch.py +++ b/homeassistant/components/ecoforest/switch.py @@ -17,7 +17,7 @@ from .entity import EcoforestEntity -@dataclass +@dataclass(frozen=True) class EcoforestSwitchRequiredKeysMixin: """Mixin for required keys.""" @@ -25,7 +25,7 @@ class EcoforestSwitchRequiredKeysMixin: switch_fn: Callable[[EcoforestApi, bool], Awaitable[Device]] -@dataclass +@dataclass(frozen=True) class EcoforestSwitchEntityDescription( SwitchEntityDescription, EcoforestSwitchRequiredKeysMixin ): diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 36cdeb688218a1..67cbd7496e32ba 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 resubscribe(now): 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 e77c4face7477a..f5328da47764ad 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/water_heater.py b/homeassistant/components/econet/water_heater.py index cbaf4551d03161..a99ab087729178 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.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 @@ async def async_update(self) -> None: """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/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 6fc6eed40f6906..809f1c531da798 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -1,6 +1,7 @@ """Support for Efergy sensors.""" from __future__ import annotations +import dataclasses from re import sub from typing import cast @@ -121,7 +122,10 @@ async def async_setup_entry( ) ) else: - description.entity_registry_enabled_default = len(api.sids) > 1 + description = dataclasses.replace( + description, + entity_registry_enabled_default=len(api.sids) > 1, + ) for sid in api.sids: sensors.append( EfergySensor( diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 8017bbf006e055..51d02781554cbf 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -28,14 +28,14 @@ ATTR_EK_HOP_END = "hop_sensor_end" -@dataclass +@dataclass(frozen=True) class ElectricKiwiHOPRequiredKeysMixin: """Mixin for required HOP keys.""" value_func: Callable[[Hop], datetime] -@dataclass +@dataclass(frozen=True) class ElectricKiwiHOPSensorEntityDescription( SensorEntityDescription, ElectricKiwiHOPRequiredKeysMixin, diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 4a67bd5211bed1..d21c0d80ca6e7a 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -19,8 +19,8 @@ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "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]" + "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/elgato/button.py b/homeassistant/components/elgato/button.py index b05cd532c16eea..9747496c1264c1 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -23,20 +23,13 @@ from .entity import ElgatoEntity -@dataclass -class ElgatoButtonEntityDescriptionMixin: - """Mixin values for Elgato entities.""" +@dataclass(frozen=True, 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/manifest.json b/homeassistant/components/elgato/manifest.json index 033a2567bb4e48..0671a7adb1d57b 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.1.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 8ed8265705c2b3..b683b80f5fafc2 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -26,20 +26,12 @@ 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(frozen=True, 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 e6b16215793c42..6e1031c8ddff58 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 78af3adfa5390a..d1f370547a4fca 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -19,21 +19,13 @@ 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(frozen=True, 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/__init__.py b/homeassistant/components/elkm1/__init__.py index b78157588e82e2..b633e1ae62091f 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -17,6 +17,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_CONNECTIONS, + CONF_ENABLED, CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, @@ -46,7 +47,6 @@ CONF_AREA, CONF_AUTO_CONFIGURE, CONF_COUNTER, - CONF_ENABLED, CONF_KEYPAD, CONF_OUTPUT, CONF_PLC, diff --git a/homeassistant/components/elkm1/const.py b/homeassistant/components/elkm1/const.py index a2bb5744c11784..9e952c7ee0b299 100644 --- a/homeassistant/components/elkm1/const.py +++ b/homeassistant/components/elkm1/const.py @@ -14,7 +14,6 @@ CONF_AUTO_CONFIGURE = "auto_configure" CONF_AREA = "area" CONF_COUNTER = "counter" -CONF_ENABLED = "enabled" CONF_KEYPAD = "keypad" CONF_OUTPUT = "output" CONF_PLC = "plc" diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index 8a6acb154aaa6a..e05b17b91714dd 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -18,13 +18,11 @@ _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/sensor.py b/homeassistant/components/emonitor/sensor.py index 6e196eebeb03be..5600cca308ef42 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -27,10 +27,12 @@ SENSORS = ( SensorEntityDescription(key="inst_power"), SensorEntityDescription( - key="avg_power", name="Average", entity_registry_enabled_default=False + key="avg_power", + translation_key="average", + entity_registry_enabled_default=False, ), SensorEntityDescription( - key="max_power", name="Max", entity_registry_enabled_default=False + key="max_power", translation_key="max", entity_registry_enabled_default=False ), ) @@ -66,6 +68,7 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): _attr_device_class = SensorDeviceClass.POWER _attr_native_unit_of_measurement = UnitOfPower.WATT _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True def __init__( self, @@ -79,9 +82,9 @@ def __init__( super().__init__(coordinator) mac_address = self.emonitor_status.network.mac_address device_name = name_short_mac(mac_address[-6:]) - label = self.channel_data.label or f"{device_name} {channel_number}" + label = self.channel_data.label or str(channel_number) if description.name is not UNDEFINED: - self._attr_name = f"{label} {description.name}" + self._attr_translation_placeholders = {"label": label} self._attr_unique_id = f"{mac_address}_{channel_number}_{description.key}" else: self._attr_name = label diff --git a/homeassistant/components/emonitor/strings.json b/homeassistant/components/emonitor/strings.json index 675db107935129..95f7f65bb988fb 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": { @@ -19,5 +22,15 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "average": { + "name": "{label} average" + }, + "max": { + "name": "{label} max" + } + } } } diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index a98d2c08a48840..1ba93da716c5a9 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -6,7 +6,6 @@ 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 6dfd49c371c85a..0730eced60ce1d 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable from functools import lru_cache import hashlib from http import HTTPStatus @@ -41,6 +42,7 @@ ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, + ColorMode, LightEntityFeature, ) from homeassistant.components.media_player import ( @@ -57,6 +59,7 @@ SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET, + STATE_CLOSED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -73,6 +76,7 @@ from .config import Config _LOGGER = logging.getLogger(__name__) +_OFF_STATES: dict[str, str] = {cover.DOMAIN: STATE_CLOSED} # How long to wait for a state change to happen STATE_CHANGE_WAIT_TIMEOUT = 5.0 @@ -113,12 +117,19 @@ {"error": {"address": "/", "description": "unauthorized user", "type": "1"}} ] -DIMMABLE_SUPPORT_FEATURES = ( - CoverEntityFeature.SET_POSITION - | FanEntityFeature.SET_SPEED - | MediaPlayerEntityFeature.VOLUME_SET - | ClimateEntityFeature.TARGET_TEMPERATURE -) +DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN = { + cover.DOMAIN: CoverEntityFeature.SET_POSITION, + fan.DOMAIN: FanEntityFeature.SET_SPEED, + media_player.DOMAIN: MediaPlayerEntityFeature.VOLUME_SET, + climate.DOMAIN: ClimateEntityFeature.TARGET_TEMPERATURE, +} + +ENTITY_FEATURES_BY_DOMAIN = { + cover.DOMAIN: CoverEntityFeature, + fan.DOMAIN: FanEntityFeature, + media_player.DOMAIN: MediaPlayerEntityFeature, + climate.DOMAIN: ClimateEntityFeature, +} @lru_cache(maxsize=32) @@ -394,7 +405,7 @@ async def put( # noqa: C901 return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) parsed[STATE_ON] = request_json[HUE_API_STATE_ON] else: - parsed[STATE_ON] = entity.state != STATE_OFF + parsed[STATE_ON] = _hass_to_hue_state(entity) for key, attr in ( (HUE_API_STATE_BRI, STATE_BRIGHTNESS), @@ -585,7 +596,7 @@ async def put( # noqa: C901 ) if service is not None: - state_will_change = parsed[STATE_ON] != (entity.state != STATE_OFF) + state_will_change = parsed[STATE_ON] != _hass_to_hue_state(entity) hass.async_create_task( hass.services.async_call(domain, service, data, blocking=True) @@ -643,7 +654,7 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: cached_state = entry_state elif time.time() - entry_time < STATE_CACHED_TIMEOUT and entry_state[ STATE_ON - ] == (entity.state != STATE_OFF): + ] == _hass_to_hue_state(entity): # We only want to use the cache if the actual state of the entity # is in sync so that it can be detected as an error by Alexa. cached_state = entry_state @@ -676,19 +687,20 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: @lru_cache(maxsize=512) def _build_entity_state_dict(entity: State) -> dict[str, Any]: """Build a state dict for an entity.""" + is_on = _hass_to_hue_state(entity) data: dict[str, Any] = { - STATE_ON: entity.state != STATE_OFF, + STATE_ON: is_on, STATE_BRIGHTNESS: None, STATE_HUE: None, STATE_SATURATION: None, STATE_COLOR_TEMP: None, } - if data[STATE_ON]: + attributes = entity.attributes + if is_on: data[STATE_BRIGHTNESS] = hass_to_hue_brightness( - entity.attributes.get(ATTR_BRIGHTNESS, 0) + attributes.get(ATTR_BRIGHTNESS) or 0 ) - hue_sat = entity.attributes.get(ATTR_HS_COLOR) - if hue_sat is not None: + if (hue_sat := attributes.get(ATTR_HS_COLOR)) is not None: hue = hue_sat[0] sat = hue_sat[1] # Convert hass hs values back to hue hs values @@ -697,7 +709,7 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: else: data[STATE_HUE] = HUE_API_STATE_HUE_MIN data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN - data[STATE_COLOR_TEMP] = entity.attributes.get(ATTR_COLOR_TEMP, 0) + data[STATE_COLOR_TEMP] = attributes.get(ATTR_COLOR_TEMP) or 0 else: data[STATE_BRIGHTNESS] = 0 @@ -706,25 +718,23 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: data[STATE_COLOR_TEMP] = 0 if entity.domain == climate.DOMAIN: - temperature = entity.attributes.get(ATTR_TEMPERATURE, 0) + temperature = attributes.get(ATTR_TEMPERATURE, 0) # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == humidifier.DOMAIN: - humidity = entity.attributes.get(ATTR_HUMIDITY, 0) + humidity = attributes.get(ATTR_HUMIDITY, 0) # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(humidity * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == media_player.DOMAIN: - level = entity.attributes.get( - ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0 - ) + level = attributes.get(ATTR_MEDIA_VOLUME_LEVEL, 1.0 if is_on else 0.0) # Convert 0.0-1.0 to 0-254 data[STATE_BRIGHTNESS] = round(min(1.0, level) * HUE_API_STATE_BRI_MAX) elif entity.domain == fan.DOMAIN: - percentage = entity.attributes.get(ATTR_PERCENTAGE) or 0 + percentage = attributes.get(ATTR_PERCENTAGE) or 0 # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(percentage * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == cover.DOMAIN: - level = entity.attributes.get(ATTR_CURRENT_POSITION, 0) + level = attributes.get(ATTR_CURRENT_POSITION, 0) data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX) _clamp_values(data) return data @@ -755,7 +765,6 @@ def _entity_unique_id(entity_id: str) -> str: def state_to_json(config: Config, state: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" - entity_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) unique_id = _entity_unique_id(state.entity_id) state_dict = get_entity_state_dict(config, state) @@ -772,8 +781,10 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: "manufacturername": "Home Assistant", "swversion": "123", } - - if light.color_supported(color_modes) and light.color_temp_supported(color_modes): + is_light = state.domain == light.DOMAIN + color_supported = is_light and light.color_supported(color_modes) + color_temp_supported = is_light and 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" @@ -791,7 +802,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" @@ -805,7 +816,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" @@ -817,9 +828,7 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], } ) - elif entity_features & DIMMABLE_SUPPORT_FEATURES or light.brightness_supported( - color_modes - ): + elif state_supports_hue_brightness(state, color_modes): # Dimmable light (Zigbee Device ID: 0x0100) # Supports groups, scenes, on/off and dimming retval["type"] = "Dimmable light" @@ -842,6 +851,21 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: return retval +def state_supports_hue_brightness( + state: State, color_modes: Iterable[ColorMode] +) -> bool: + """Return True if the state supports brightness.""" + domain = state.domain + if domain == light.DOMAIN: + return light.brightness_supported(color_modes) + if not (required_feature := DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN.get(domain)): + return False + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + enum = ENTITY_FEATURES_BY_DOMAIN[domain] + features = enum(features) if type(features) is int else features # noqa: E721 + return required_feature in features + + def create_hue_success_response( entity_number: str, attr: str, value: str ) -> dict[str, Any]: @@ -890,6 +914,11 @@ def hass_to_hue_brightness(value: int) -> int: return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) +def _hass_to_hue_state(entity: State) -> bool: + """Convert hass entity states to simple True/False on/off state for Hue.""" + return entity.state != _OFF_STATES.get(entity.domain, STATE_OFF) + + async def wait_for_state_change_or_timeout( hass: core.HomeAssistant, entity_id: str, timeout: float ) -> None: diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index e9760a96aa4e1f..834a9bbb1ebc89 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -317,6 +317,11 @@ def _update_cost(self) -> None: try: energy_price = float(energy_price_state.state) except ValueError: + if self._last_energy_sensor_state is None: + # Initialize as it's the first time all required entities except + # price are in place. This means that the cost will update the first + # time the energy is updated after the price entity is in place. + self._reset(energy_state) return energy_price_unit: str | None = energy_price_state.attributes.get( diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py index 096e312efc0818..8878a99e562a7d 100644 --- a/homeassistant/components/energyzero/__init__.py +++ b/homeassistant/components/energyzero/__init__.py @@ -5,11 +5,23 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import EnergyZeroDataUpdateCoordinator +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up EnergyZero services.""" + + async_setup_services(hass) + + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -25,6 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 8e2b8aba8945cf..025f929a4f6d82 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==2.1.0"] } diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 2468e5e68bf550..59c44c1aad87ce 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -29,7 +29,7 @@ from .coordinator import EnergyZeroData, EnergyZeroDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class EnergyZeroSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -37,7 +37,7 @@ class EnergyZeroSensorEntityDescriptionMixin: service_type: str -@dataclass +@dataclass(frozen=True) class EnergyZeroSensorEntityDescription( SensorEntityDescription, EnergyZeroSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py new file mode 100644 index 00000000000000..d8e548c22f8d6a --- /dev/null +++ b/homeassistant/components/energyzero/services.py @@ -0,0 +1,166 @@ +"""The EnergyZero services.""" +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum +from functools import partial +from typing import Final + +from energyzero import Electricity, Gas, VatOption +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import selector +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import EnergyZeroDataUpdateCoordinator + +ATTR_CONFIG_ENTRY: Final = "config_entry" +ATTR_START: Final = "start" +ATTR_END: Final = "end" +ATTR_INCL_VAT: Final = "incl_vat" + +GAS_SERVICE_NAME: Final = "get_gas_prices" +ENERGY_SERVICE_NAME: Final = "get_energy_prices" +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(ATTR_INCL_VAT): bool, + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, + } +) + + +class PriceType(Enum): + """Type of price.""" + + ENERGY = "energy" + GAS = "gas" + + +def __get_date(date_input: str | None) -> date | datetime: + """Get date.""" + if not date_input: + return dt_util.now().date() + + if value := dt_util.parse_datetime(date_input): + return value + + raise ServiceValidationError( + "Invalid datetime provided.", + translation_domain=DOMAIN, + translation_key="invalid_date", + translation_placeholders={ + "date": date_input, + }, + ) + + +def __serialize_prices(prices: Electricity | Gas) -> ServiceResponse: + """Serialize prices.""" + return { + "prices": [ + { + key: str(value) if isinstance(value, datetime) else value + for key, value in timestamp_price.items() + } + for timestamp_price in prices.timestamp_prices + ] + } + + +def __get_coordinator( + hass: HomeAssistant, call: ServiceCall +) -> EnergyZeroDataUpdateCoordinator: + """Get the coordinator from the entry.""" + entry_id: str = call.data[ATTR_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + + if not entry: + raise ServiceValidationError( + f"Invalid config entry: {entry_id}", + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry": entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + f"{entry.title} is not loaded", + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry": entry.title, + }, + ) + + return hass.data[DOMAIN][entry_id] + + +async def __get_prices( + call: ServiceCall, + *, + hass: HomeAssistant, + price_type: PriceType, +) -> ServiceResponse: + coordinator = __get_coordinator(hass, call) + + start = __get_date(call.data.get(ATTR_START)) + end = __get_date(call.data.get(ATTR_END)) + + vat = VatOption.INCLUDE + + if call.data.get(ATTR_INCL_VAT) is False: + vat = VatOption.EXCLUDE + + data: Electricity | Gas + + if price_type == PriceType.GAS: + data = await coordinator.energyzero.gas_prices( + start_date=start, + end_date=end, + vat=vat, + ) + else: + data = await coordinator.energyzero.energy_prices( + start_date=start, + end_date=end, + vat=vat, + ) + + return __serialize_prices(data) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up EnergyZero services.""" + + hass.services.async_register( + DOMAIN, + GAS_SERVICE_NAME, + partial(__get_prices, hass=hass, price_type=PriceType.GAS), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + ENERGY_SERVICE_NAME, + partial(__get_prices, hass=hass, price_type=PriceType.ENERGY), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/energyzero/services.yaml b/homeassistant/components/energyzero/services.yaml new file mode 100644 index 00000000000000..dc8df9aa6d0c4c --- /dev/null +++ b/homeassistant/components/energyzero/services.yaml @@ -0,0 +1,44 @@ +get_gas_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: energyzero + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: +get_energy_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: energyzero + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index a27ce236c281e3..9858838aff7f01 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -9,6 +9,17 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "exceptions": { + "invalid_date": { + "message": "Invalid date provided. Got {date}" + }, + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + }, + "unloaded_config_entry": { + "message": "Invalid config entry provided. {config_entry} is not loaded." + } + }, "entity": { "sensor": { "current_hour_price": { @@ -39,5 +50,51 @@ "name": "Hours priced equal or lower than current - today" } } + }, + "services": { + "get_gas_prices": { + "name": "Get gas prices", + "description": "Request gas prices from EnergyZero.", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, + "incl_vat": { + "name": "Including VAT", + "description": "Include VAT in the prices." + }, + "start": { + "name": "Start", + "description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted." + }, + "end": { + "name": "End", + "description": "Specifies the date and time until which to retrieve prices. Defaults to today if omitted." + } + } + }, + "get_energy_prices": { + "name": "Get energy prices", + "description": "Request energy prices from EnergyZero.", + "fields": { + "config_entry": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::description%]" + }, + "incl_vat": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::description%]" + }, + "start": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::start::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::start::description%]" + }, + "end": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::end::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::end::description%]" + } + } + } } } diff --git a/homeassistant/components/enigma2/const.py b/homeassistant/components/enigma2/const.py new file mode 100644 index 00000000000000..0511a79417267c --- /dev/null +++ b/homeassistant/components/enigma2/const.py @@ -0,0 +1,17 @@ +"""Constants for the Enigma2 platform.""" +DOMAIN = "enigma2" + +CONF_USE_CHANNEL_ICON = "use_channel_icon" +CONF_DEEP_STANDBY = "deep_standby" +CONF_SOURCE_BOUQUET = "source_bouquet" +CONF_MAC_ADDRESS = "mac_address" + +DEFAULT_NAME = "Enigma2 Media Player" +DEFAULT_PORT = 80 +DEFAULT_SSL = False +DEFAULT_USE_CHANNEL_ICON = False +DEFAULT_USERNAME = "root" +DEFAULT_PASSWORD = "dreambox" +DEFAULT_DEEP_STANDBY = False +DEFAULT_SOURCE_BOUQUET = "" +DEFAULT_MAC_ADDRESS = "" diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 932cbda66ec3e2..42fbcb5b9bc605 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -1,9 +1,9 @@ { "domain": "enigma2", "name": "Enigma2 (OpenWebif)", - "codeowners": ["@fbradyirl"], + "codeowners": ["@autinerd"], "documentation": "https://www.home-assistant.io/integrations/enigma2", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==3.2.7"] + "requirements": ["openwebifpy==4.0.4"] } diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index a479590f4648db..598ab1afffecbe 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -1,7 +1,8 @@ """Support for Enigma2 media players.""" from __future__ import annotations -from openwebif.api import CreateDevice +from openwebif.api import OpenWebIfDevice +from openwebif.enums import RemoteControlCodes, SetVolumeOption import voluptuous as vol from homeassistant.components.media_player import ( @@ -24,26 +25,27 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import ( + CONF_DEEP_STANDBY, + CONF_MAC_ADDRESS, + CONF_SOURCE_BOUQUET, + CONF_USE_CHANNEL_ICON, + DEFAULT_DEEP_STANDBY, + DEFAULT_MAC_ADDRESS, + DEFAULT_NAME, + DEFAULT_PASSWORD, + DEFAULT_PORT, + DEFAULT_SOURCE_BOUQUET, + DEFAULT_SSL, + DEFAULT_USE_CHANNEL_ICON, + DEFAULT_USERNAME, +) + ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_DESCRIPTION = "media_description" ATTR_MEDIA_END_TIME = "media_end_time" ATTR_MEDIA_START_TIME = "media_start_time" -CONF_USE_CHANNEL_ICON = "use_channel_icon" -CONF_DEEP_STANDBY = "deep_standby" -CONF_MAC_ADDRESS = "mac_address" -CONF_SOURCE_BOUQUET = "source_bouquet" - -DEFAULT_NAME = "Enigma2 Media Player" -DEFAULT_PORT = 80 -DEFAULT_SSL = False -DEFAULT_USE_CHANNEL_ICON = False -DEFAULT_USERNAME = "root" -DEFAULT_PASSWORD = "dreambox" -DEFAULT_DEEP_STANDBY = False -DEFAULT_MAC_ADDRESS = "" -DEFAULT_SOURCE_BOUQUET = "" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -62,10 +64,10 @@ ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up of an enigma2 media player.""" @@ -84,24 +86,26 @@ def setup_platform( config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET - device = CreateDevice( + device = OpenWebIfDevice( host=config[CONF_HOST], port=config.get(CONF_PORT), username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), is_https=config[CONF_SSL], - prefer_picon=config.get(CONF_USE_CHANNEL_ICON), - mac_address=config.get(CONF_MAC_ADDRESS), turn_off_to_deep=config.get(CONF_DEEP_STANDBY), source_bouquet=config.get(CONF_SOURCE_BOUQUET), ) - add_devices([Enigma2Device(config[CONF_NAME], device)], True) + async_add_entities( + [Enigma2Device(config[CONF_NAME], device, await device.get_about())] + ) class Enigma2Device(MediaPlayerEntity): """Representation of an Enigma2 box.""" + _attr_has_entity_name = True + _attr_media_content_type = MediaType.TVSHOW _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET @@ -116,146 +120,92 @@ class Enigma2Device(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, name, device): + def __init__(self, name: str, device: OpenWebIfDevice, about: dict) -> None: """Initialize the Enigma2 device.""" - self._name = name - self.e2_box = device - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID for this entity.""" - return self.e2_box.mac_address - - @property - def state(self) -> MediaPlayerState: - """Return the state of the device.""" - if self.e2_box.is_recording_playback: - return MediaPlayerState.PLAYING - return MediaPlayerState.OFF if self.e2_box.in_standby else MediaPlayerState.ON - - @property - def available(self) -> bool: - """Return True if the device is available.""" - return not self.e2_box.is_offline - - def turn_off(self) -> None: + self._device: OpenWebIfDevice = device + self._device.mac_address = about["info"]["ifaces"][0]["mac"] + + self._attr_name = name + self._attr_unique_id = device.mac_address + + async def async_turn_off(self) -> None: """Turn off media player.""" - self.e2_box.turn_off() + await self._device.turn_off() - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn the media player on.""" - self.e2_box.turn_on() - - @property - def media_title(self): - """Title of current playing media.""" - return self.e2_box.current_service_channel_name - - @property - def media_series_title(self): - """Return the title of current episode of TV show.""" - return self.e2_box.current_programme_name - - @property - def media_channel(self): - """Channel of current playing media.""" - return self.e2_box.current_service_channel_name - - @property - def media_content_id(self): - """Service Ref of current playing media.""" - return self.e2_box.current_service_ref - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self.e2_box.muted - - @property - def media_image_url(self): - """Picon url for the channel.""" - return self.e2_box.picon_url - - def set_volume_level(self, volume: float) -> None: + await self._device.turn_on() + + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self.e2_box.set_volume(int(volume * 100)) + await self._device.set_volume(int(volume * 100)) - def volume_up(self) -> None: + async def async_volume_up(self) -> None: """Volume up the media player.""" - self.e2_box.set_volume(int(self.e2_box.volume * 100) + 5) + await self._device.set_volume(SetVolumeOption.UP) - def volume_down(self) -> None: + async def async_volume_down(self) -> None: """Volume down media player.""" - self.e2_box.set_volume(int(self.e2_box.volume * 100) - 5) - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self.e2_box.volume + await self._device.set_volume(SetVolumeOption.DOWN) - def media_stop(self) -> None: + async def async_media_stop(self) -> None: """Send stop command.""" - self.e2_box.set_stop() + await self._device.send_remote_control_action(RemoteControlCodes.STOP) - def media_play(self) -> None: + async def async_media_play(self) -> None: """Play media.""" - self.e2_box.toggle_play_pause() + await self._device.send_remote_control_action(RemoteControlCodes.PLAY) - def media_pause(self) -> None: + async def async_media_pause(self) -> None: """Pause the media player.""" - self.e2_box.toggle_play_pause() + await self._device.send_remote_control_action(RemoteControlCodes.PAUSE) - def media_next_track(self) -> None: + async def async_media_next_track(self) -> None: """Send next track command.""" - self.e2_box.set_channel_up() + await self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_UP) - def media_previous_track(self) -> None: + async def async_media_previous_track(self) -> None: """Send next track command.""" - self.e2_box.set_channel_down() + self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_DOWN) - def mute_volume(self, mute: bool) -> None: + async def async_mute_volume(self, mute: bool) -> None: """Mute or unmute.""" - self.e2_box.mute_volume() - - @property - def source(self): - """Return the current input source.""" - return self.e2_box.current_service_channel_name - - @property - def source_list(self): - """List of available input sources.""" - return self.e2_box.source_list + await self._device.toggle_mute() - def select_source(self, source: str) -> None: + async def async_select_source(self, source: str) -> None: """Select input source.""" - self.e2_box.select_source(self.e2_box.sources[source]) + await self._device.zap(self._device.sources[source]) - def update(self) -> None: + async def async_update(self) -> None: """Update state of the media_player.""" - self.e2_box.update() - - @property - def extra_state_attributes(self): - """Return device specific state attributes. - - isRecording: Is the box currently recording. - currservice_fulldescription: Full program description. - currservice_begin: is in the format '21:00'. - currservice_end: is in the format '21:00'. - """ - if self.e2_box.in_standby: - return {} - return { - ATTR_MEDIA_CURRENTLY_RECORDING: self.e2_box.status_info["isRecording"], - ATTR_MEDIA_DESCRIPTION: self.e2_box.status_info[ - "currservice_fulldescription" - ], - ATTR_MEDIA_START_TIME: self.e2_box.status_info["currservice_begin"], - ATTR_MEDIA_END_TIME: self.e2_box.status_info["currservice_end"], - } + await self._device.update() + self._attr_available = not self._device.is_offline + + if not self._device.status.in_standby: + self._attr_extra_state_attributes = { + ATTR_MEDIA_CURRENTLY_RECORDING: self._device.status.is_recording, + ATTR_MEDIA_DESCRIPTION: self._device.status.currservice.fulldescription, + ATTR_MEDIA_START_TIME: self._device.status.currservice.begin, + ATTR_MEDIA_END_TIME: self._device.status.currservice.end, + } + else: + self._attr_extra_state_attributes = {} + + self._attr_media_title = self._device.status.currservice.station + self._attr_media_series_title = self._device.status.currservice.name + self._attr_media_channel = self._device.status.currservice.station + self._attr_is_volume_muted = self._device.status.muted + self._attr_media_content_id = self._device.status.currservice.serviceref + self._attr_media_image_url = self._device.picon_url + self._attr_source = self._device.status.currservice.station + self._attr_source_list = self._device.source_list + + if self._device.status.in_standby: + self._attr_state = MediaPlayerState.OFF + else: + self._attr_state = MediaPlayerState.ON + + if (volume_level := self._device.status.volume) is not None: + self._attr_volume_level = volume_level / 100 + else: + self._attr_volume_level = None diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index f63fd7239d0126..83c801d598e857 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -44,14 +44,14 @@ SENSOR_TYPE_WINDOWHANDLE = "windowhandle" -@dataclass +@dataclass(frozen=True) class EnOceanSensorEntityDescriptionMixin: """Mixin for required keys.""" unique_id: Callable[[list[int]], str | None] -@dataclass +@dataclass(frozen=True) class EnOceanSensorEntityDescription( SensorEntityDescription, EnOceanSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 7060943deb8361..5eb2e621e474f3 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -22,14 +22,14 @@ from .entity import EnvoyBaseEntity -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEncharge], bool] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeBinarySensorEntityDescription( BinarySensorEntityDescription, EnvoyEnchargeRequiredKeysMixin ): @@ -53,14 +53,14 @@ class EnvoyEnchargeBinarySensorEntityDescription( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnpower], bool] -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerBinarySensorEntityDescription( BinarySensorEntityDescription, EnvoyEnpowerRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 75f2ef3928974d..02a9d2f249191b 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -144,7 +144,10 @@ async def _async_update_data(self) -> dict[str, Any]: if not self._setup_complete: await self._async_setup_and_authenticate() self._async_mark_setup_complete() - return (await envoy.update()).raw + # dump all received data in debug mode to assist troubleshooting + envoy_data = await envoy.update() + _LOGGER.debug("Envoy data: %s", envoy_data) + return envoy_data.raw except INVALID_AUTH_ERRORS as err: if self._setup_complete and tries == 0: # token likely expired or firmware changed, try to re-authenticate diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 1d589cfb176242..7b8a3e0327022f 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -39,6 +39,7 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { "entry": entry.as_dict(), + "envoy_firmware": coordinator.envoy.firmware, "data": coordinator.data, }, TO_REDACT, diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 718c33d281156a..4ae7760a56bdf8 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -1,12 +1,12 @@ { "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", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.14.2"], + "requirements": ["pyenphase==1.15.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 918e4002e7a1b1..bf54c91f45b3fd 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -25,21 +25,21 @@ from .entity import EnvoyBaseEntity -@dataclass +@dataclass(frozen=True) class EnvoyRelayRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyDryContactSettings], float] -@dataclass +@dataclass(frozen=True) class EnvoyRelayNumberEntityDescription( NumberEntityDescription, EnvoyRelayRequiredKeysMixin ): """Describes an Envoy Dry Contact Relay number entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -47,7 +47,7 @@ class EnvoyStorageSettingsRequiredKeysMixin: update_fn: Callable[[Envoy, float], Awaitable[dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsNumberEntityDescription( NumberEntityDescription, EnvoyStorageSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 331d2a999ada75..5d2edf91d9a13b 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -21,7 +21,7 @@ from .entity import EnvoyBaseEntity -@dataclass +@dataclass(frozen=True) class EnvoyRelayRequiredKeysMixin: """Mixin for required keys.""" @@ -31,14 +31,14 @@ class EnvoyRelayRequiredKeysMixin: ] -@dataclass +@dataclass(frozen=True) class EnvoyRelaySelectEntityDescription( SelectEntityDescription, EnvoyRelayRequiredKeysMixin ): """Describes an Envoy Dry Contact Relay select entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -46,7 +46,7 @@ class EnvoyStorageSettingsRequiredKeysMixin: update_fn: Callable[[Envoy, str], Awaitable[dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsSelectEntityDescription( SelectEntityDescription, EnvoyStorageSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 33b9e3a64dfe38..1dfd72dcaf3165 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -47,14 +47,14 @@ LAST_REPORTED_KEY = "last_reported" -@dataclass +@dataclass(frozen=True) class EnvoyInverterRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyInverter], datetime.datetime | float] -@dataclass +@dataclass(frozen=True) class EnvoyInverterSensorEntityDescription( SensorEntityDescription, EnvoyInverterRequiredKeysMixin ): @@ -80,14 +80,14 @@ class EnvoyInverterSensorEntityDescription( ) -@dataclass +@dataclass(frozen=True) class EnvoyProductionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemProduction], int] -@dataclass +@dataclass(frozen=True) class EnvoyProductionSensorEntityDescription( SensorEntityDescription, EnvoyProductionRequiredKeysMixin ): @@ -137,14 +137,14 @@ class EnvoyProductionSensorEntityDescription( ) -@dataclass +@dataclass(frozen=True) class EnvoyConsumptionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemConsumption], int] -@dataclass +@dataclass(frozen=True) class EnvoyConsumptionSensorEntityDescription( SensorEntityDescription, EnvoyConsumptionRequiredKeysMixin ): @@ -194,28 +194,28 @@ class EnvoyConsumptionSensorEntityDescription( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEncharge], datetime.datetime | int | float] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeSensorEntityDescription( SensorEntityDescription, EnvoyEnchargeRequiredKeysMixin ): """Describes an Envoy Encharge sensor entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyEnchargePowerRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnchargePower], int | float] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargePowerSensorEntityDescription( SensorEntityDescription, EnvoyEnchargePowerRequiredKeysMixin ): @@ -259,14 +259,14 @@ class EnvoyEnchargePowerSensorEntityDescription( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnpower], datetime.datetime | int | float] -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerSensorEntityDescription( SensorEntityDescription, EnvoyEnpowerRequiredKeysMixin ): @@ -289,14 +289,14 @@ class EnvoyEnpowerSensorEntityDescription( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeAggregateRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnchargeAggregate], int] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeAggregateSensorEntityDescription( SensorEntityDescription, EnvoyEnchargeAggregateRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 94cf923374559b..fe32002e6b2ad7 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/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 22746fd9479f21..76c73914db68a9 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerRequiredKeysMixin: """Mixin for required keys.""" @@ -33,14 +33,14 @@ class EnvoyEnpowerRequiredKeysMixin: turn_off_fn: Callable[[Envoy], Coroutine[Any, Any, dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerSwitchEntityDescription( SwitchEntityDescription, EnvoyEnpowerRequiredKeysMixin ): """Describes an Envoy Enpower switch entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyDryContactRequiredKeysMixin: """Mixin for required keys.""" @@ -49,14 +49,14 @@ class EnvoyDryContactRequiredKeysMixin: turn_off_fn: Callable[[Envoy, str], Coroutine[Any, Any, dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyDryContactSwitchEntityDescription( SwitchEntityDescription, EnvoyDryContactRequiredKeysMixin ): """Describes an Envoy Enpower dry contact switch entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -65,7 +65,7 @@ class EnvoyStorageSettingsRequiredKeysMixin: turn_off_fn: Callable[[Envoy], Awaitable[dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsSwitchEntityDescription( SwitchEntityDescription, EnvoyStorageSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 64a4b7dad20cab..14fb3e8e54c8fb 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -6,13 +6,13 @@ from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_LANGUAGE, CONF_STATION, DOMAIN +from .const import CONF_STATION, DOMAIN DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index 07b6eac0da0350..f4b9ee792c3964 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -7,10 +7,10 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers import config_validation as cv -from .const import CONF_LANGUAGE, CONF_STATION, CONF_TITLE, DOMAIN +from .const import CONF_STATION, CONF_TITLE, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py index 16f7dc1cf9902c..f1f6db2e0df9bb 100644 --- a/homeassistant/components/environment_canada/const.py +++ b/homeassistant/components/environment_canada/const.py @@ -2,7 +2,6 @@ ATTR_OBSERVATION_TIME = "observation_time" ATTR_STATION = "station" -CONF_LANGUAGE = "language" CONF_STATION = "station" CONF_TITLE = "title" DOMAIN = "environment_canada" diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 987a779d2e84d6..9ec4971f5731fe 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -33,14 +33,14 @@ ATTR_TIME = "alert time" -@dataclass +@dataclass(frozen=True) class ECSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Any], Any] -@dataclass +@dataclass(frozen=True) class ECSensorEntityDescription( SensorEntityDescription, ECSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json index c048687c906805..093ebf77ebae0f 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 1f80be9fe06a09..1f401ed0a7dc41 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -37,7 +37,10 @@ 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 4e3780322e9301..94544c32d1d60b 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 f32eba6944f32f..00000000000000 --- 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 700bc61293f84a..00000000000000 --- 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 af90acbde551de..00000000000000 --- 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 8a976b25c7a5c6..00000000000000 --- 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.py b/homeassistant/components/esphome/bluetooth.py new file mode 100644 index 00000000000000..24524233a70e52 --- /dev/null +++ b/homeassistant/components/esphome/bluetooth.py @@ -0,0 +1,44 @@ +"""Bluetooth support for esphome.""" +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING + +from aioesphomeapi import APIClient, DeviceInfo +from bleak_esphome import connect_scanner +from bleak_esphome.backend.cache import ESPHomeBluetoothCache + +from homeassistant.components.bluetooth import async_register_scanner +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback + +from .entry_data import RuntimeEntryData + + +@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_data: RuntimeEntryData, + cli: APIClient, + device_info: DeviceInfo, + cache: ESPHomeBluetoothCache, +) -> CALLBACK_TYPE: + """Connect scanner.""" + client_data = await connect_scanner(cli, device_info, cache, entry_data.available) + entry_data.bluetooth_device = client_data.bluetooth_device + client_data.disconnect_callbacks = entry_data.disconnect_callbacks + scanner = client_data.scanner + if TYPE_CHECKING: + assert scanner is not None + return partial( + _async_unload, + [ + async_register_scanner(hass, scanner), + scanner.async_setup(), + ], + ) diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py deleted file mode 100644 index 9ef298145d3158..00000000000000 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Bluetooth support for esphome.""" -from __future__ import annotations - -from functools import partial -import logging - -from aioesphomeapi import APIClient, BluetoothProxyFeature - -from homeassistant.components.bluetooth import ( - HaBluetoothConnector, - async_get_advertisement_callback, - async_register_scanner, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback - -from ..entry_data import RuntimeEntryData -from .cache import ESPHomeBluetoothCache -from .client import ESPHomeClient, ESPHomeClientData -from .device import ESPHomeBluetoothDevice -from .scanner import ESPHomeScanner - -_LOGGER = logging.getLogger(__name__) - - -@hass_callback -def _async_can_connect( - entry_data: RuntimeEntryData, bluetooth_device: ESPHomeBluetoothDevice, source: str -) -> bool: - """Check if a given source can make another connection.""" - can_connect = bool(entry_data.available and bluetooth_device.ble_connections_free) - _LOGGER.debug( - ( - "%s [%s]: Checking can connect, available=%s, ble_connections_free=%s" - " result=%s" - ), - entry_data.name, - source, - entry_data.available, - bluetooth_device.ble_connections_free, - can_connect, - ) - return can_connect - - -async def async_connect_scanner( - hass: HomeAssistant, - entry: ConfigEntry, - cli: APIClient, - entry_data: RuntimeEntryData, - cache: ESPHomeBluetoothCache, -) -> CALLBACK_TYPE: - """Connect scanner.""" - assert entry.unique_id is not None - source = str(entry.unique_id) - new_info_callback = async_get_advertisement_callback(hass) - device_info = entry_data.device_info - assert device_info is not None - feature_flags = device_info.bluetooth_proxy_feature_flags_compat( - entry_data.api_version - ) - connectable = bool(feature_flags & BluetoothProxyFeature.ACTIVE_CONNECTIONS) - bluetooth_device = ESPHomeBluetoothDevice(entry_data.name, device_info.mac_address) - entry_data.bluetooth_device = bluetooth_device - _LOGGER.debug( - "%s [%s]: Connecting scanner feature_flags=%s, connectable=%s", - entry.title, - source, - feature_flags, - connectable, - ) - client_data = ESPHomeClientData( - bluetooth_device=bluetooth_device, - cache=cache, - client=cli, - device_info=device_info, - api_version=entry_data.api_version, - title=entry.title, - scanner=None, - disconnect_callbacks=entry_data.disconnect_callbacks, - ) - connector = HaBluetoothConnector( - # MyPy doesn't like partials, but this is correct - # https://github.com/python/mypy/issues/1484 - client=partial(ESPHomeClient, client_data=client_data), # type: ignore[arg-type] - source=source, - can_connect=hass_callback( - partial(_async_can_connect, entry_data, bluetooth_device, source) - ), - ) - scanner = ESPHomeScanner( - hass, source, entry.title, new_info_callback, connector, connectable - ) - client_data.scanner = scanner - 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 - ) - 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 - ) - else: - await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) - - @hass_callback - def _async_unload() -> None: - for callback in unload_callbacks: - callback() - - return _async_unload diff --git a/homeassistant/components/esphome/bluetooth/cache.py b/homeassistant/components/esphome/bluetooth/cache.py deleted file mode 100644 index 3ec29121382ab3..00000000000000 --- a/homeassistant/components/esphome/bluetooth/cache.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Bluetooth cache for esphome.""" -from __future__ import annotations - -from collections.abc import MutableMapping -from dataclasses import dataclass, field - -from bleak.backends.service import BleakGATTServiceCollection -from lru import LRU # pylint: disable=no-name-in-module - -MAX_CACHED_SERVICES = 128 - - -@dataclass(slots=True) -class ESPHomeBluetoothCache: - """Shared cache between all ESPHome bluetooth devices.""" - - _gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) - ) - _gatt_mtu_cache: MutableMapping[int, int] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) - ) - - def get_gatt_services_cache( - self, address: int - ) -> BleakGATTServiceCollection | None: - """Get the BleakGATTServiceCollection for the given address.""" - return self._gatt_services_cache.get(address) - - def set_gatt_services_cache( - self, address: int, services: BleakGATTServiceCollection - ) -> None: - """Set the BleakGATTServiceCollection for the given address.""" - self._gatt_services_cache[address] = services - - def clear_gatt_services_cache(self, address: int) -> None: - """Clear the BleakGATTServiceCollection for the given address.""" - self._gatt_services_cache.pop(address, None) - - def get_gatt_mtu_cache(self, address: int) -> int | None: - """Get the mtu cache for the given address.""" - return self._gatt_mtu_cache.get(address) - - def set_gatt_mtu_cache(self, address: int, mtu: int) -> None: - """Set the mtu cache for the given address.""" - self._gatt_mtu_cache[address] = mtu - - def clear_gatt_mtu_cache(self, address: int) -> None: - """Clear the mtu cache for the given address.""" - self._gatt_mtu_cache.pop(address, None) diff --git a/homeassistant/components/esphome/bluetooth/characteristic.py b/homeassistant/components/esphome/bluetooth/characteristic.py deleted file mode 100644 index 0db73dd3d5f924..00000000000000 --- a/homeassistant/components/esphome/bluetooth/characteristic.py +++ /dev/null @@ -1,95 +0,0 @@ -"""BleakGATTCharacteristicESPHome.""" -from __future__ import annotations - -import contextlib -from uuid import UUID - -from aioesphomeapi.model import BluetoothGATTCharacteristic -from bleak.backends.characteristic import BleakGATTCharacteristic -from bleak.backends.descriptor import BleakGATTDescriptor - -PROPERTY_MASKS = { - 2**n: prop - for n, prop in enumerate( - ( - "broadcast", - "read", - "write-without-response", - "write", - "notify", - "indicate", - "authenticated-signed-writes", - "extended-properties", - "reliable-writes", - "writable-auxiliaries", - ) - ) -} - - -class BleakGATTCharacteristicESPHome(BleakGATTCharacteristic): - """GATT Characteristic implementation for the ESPHome backend.""" - - obj: BluetoothGATTCharacteristic - - def __init__( - self, - obj: BluetoothGATTCharacteristic, - max_write_without_response_size: int, - service_uuid: str, - service_handle: int, - ) -> None: - """Init a BleakGATTCharacteristicESPHome.""" - super().__init__(obj, max_write_without_response_size) - self.__descriptors: list[BleakGATTDescriptor] = [] - self.__service_uuid: str = service_uuid - self.__service_handle: int = service_handle - char_props = self.obj.properties - self.__props: list[str] = [ - prop for mask, prop in PROPERTY_MASKS.items() if char_props & mask - ] - - @property - def service_uuid(self) -> str: - """Uuid of the Service containing this characteristic.""" - return self.__service_uuid - - @property - def service_handle(self) -> int: - """Integer handle of the Service containing this characteristic.""" - return self.__service_handle - - @property - def handle(self) -> int: - """Integer handle for this characteristic.""" - return self.obj.handle - - @property - def uuid(self) -> str: - """Uuid of this characteristic.""" - return self.obj.uuid - - @property - def properties(self) -> list[str]: - """Properties of this characteristic.""" - return self.__props - - @property - def descriptors(self) -> list[BleakGATTDescriptor]: - """List of descriptors for this service.""" - return self.__descriptors - - def get_descriptor(self, specifier: int | str | UUID) -> BleakGATTDescriptor | None: - """Get a descriptor by handle (int) or UUID (str or uuid.UUID).""" - with contextlib.suppress(StopIteration): - if isinstance(specifier, int): - return next(filter(lambda x: x.handle == specifier, self.descriptors)) - return next(filter(lambda x: x.uuid == str(specifier), self.descriptors)) - return None - - def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None: - """Add a :py:class:`~BleakGATTDescriptor` to the characteristic. - - Should not be used by end user, but rather by `bleak` itself. - """ - self.__descriptors.append(descriptor) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py deleted file mode 100644 index 970e866b27bf39..00000000000000 --- a/homeassistant/components/esphome/bluetooth/client.py +++ /dev/null @@ -1,785 +0,0 @@ -"""Bluetooth client for esphome.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable, Coroutine -import contextlib -from dataclasses import dataclass, field -from functools import partial -import logging -import sys -from typing import Any, TypeVar, cast -import uuid - -if sys.version_info < (3, 12): - from typing_extensions import Buffer -else: - from collections.abc import Buffer - -from aioesphomeapi import ( - ESP_CONNECTION_ERROR_DESCRIPTION, - ESPHOME_GATT_ERRORS, - APIClient, - APIVersion, - BLEConnectionError, - BluetoothProxyFeature, - DeviceInfo, -) -from aioesphomeapi.core import ( - APIConnectionError, - 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 -from bleak.backends.service import BleakGATTServiceCollection -from bleak.exc import BleakError - -from homeassistant.core import CALLBACK_TYPE - -from .cache import ESPHomeBluetoothCache -from .characteristic import BleakGATTCharacteristicESPHome -from .descriptor import BleakGATTDescriptorESPHome -from .device import ESPHomeBluetoothDevice -from .scanner import ESPHomeScanner -from .service import BleakGATTServiceESPHome - -DEFAULT_MTU = 23 -GATT_HEADER_SIZE = 3 -DISCONNECT_TIMEOUT = 5.0 -CONNECT_FREE_SLOT_TIMEOUT = 2.0 -GATT_READ_TIMEOUT = 30.0 - -# CCCD (Characteristic Client Config Descriptor) -CCCD_UUID = "00002902-0000-1000-8000-00805f9b34fb" -CCCD_NOTIFY_BYTES = b"\x01\x00" -CCCD_INDICATE_BYTES = b"\x02\x00" - -DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE -_LOGGER = logging.getLogger(__name__) - -_WrapFuncType = TypeVar("_WrapFuncType", bound=Callable[..., Any]) - - -def mac_to_int(address: str) -> int: - """Convert a mac address to an integer.""" - 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 - loop = self._loop - disconnected_futures = self._disconnected_futures - disconnected_future = loop.create_future() - disconnected_futures.add(disconnected_future) - ble_device = self._ble_device - disconnect_message = ( - f"{self._source_name }: {ble_device.name} - {ble_device.address}: " - "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: - try: - return await func(self, *args, **kwargs) - except TimeoutAPIError as err: - raise asyncio.TimeoutError(str(err)) from err - 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 - # the proxy knows to reconnect. - # - # Because callbacks are delivered asynchronously it's possible - # that we find out about the disconnection during the operation - # before the callback is delivered. - - if ex.error.error == -1: - # pylint: disable=protected-access - _LOGGER.debug( - "%s: %s - %s: BLE device disconnected during %s operation", - self._source_name, - self._ble_device.name, - self._ble_device.address, - func.__name__, - ) - self._async_ble_device_disconnected() - raise BleakError(str(ex)) from ex - except APIConnectionError as err: - raise BleakError(str(err)) from err - - return cast(_WrapFuncType, _async_wrap_bluetooth_operation) - - -@dataclass(slots=True) -class ESPHomeClientData: - """Define a class that stores client data for an esphome client.""" - - bluetooth_device: ESPHomeBluetoothDevice - cache: ESPHomeBluetoothCache - client: APIClient - device_info: DeviceInfo - api_version: APIVersion - title: str - scanner: ESPHomeScanner | None - disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) - - -class ESPHomeClient(BaseBleakClient): - """ESPHome Bleak Client.""" - - def __init__( - self, - address_or_ble_device: BLEDevice | str, - *args: Any, - client_data: ESPHomeClientData, - **kwargs: Any, - ) -> None: - """Initialize the ESPHomeClient.""" - device_info = client_data.device_info - self._disconnect_callbacks = client_data.disconnect_callbacks - assert isinstance(address_or_ble_device, BLEDevice) - super().__init__(address_or_ble_device, *args, **kwargs) - self._loop = asyncio.get_running_loop() - self._ble_device = address_or_ble_device - self._address_as_int = mac_to_int(self._ble_device.address) - assert self._ble_device.details is not None - self._source = self._ble_device.details["source"] - self._cache = client_data.cache - self._bluetooth_device = client_data.bluetooth_device - self._client = client_data.client - self._is_connected = False - self._mtu: int | None = None - self._cancel_connection_state: CALLBACK_TYPE | None = None - 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 - ) - self._address_type = address_or_ble_device.details["address_type"] - self._source_name = f"{client_data.title} [{self._source}]" - scanner = client_data.scanner - assert scanner is not None - self._scanner = scanner - - 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: %s - %s: Failed to unsubscribe from connection state (likely" - " connection dropped): %s" - ), - self._source_name, - self._ble_device.name, - self._ble_device.address, - ex, - ) - self._cancel_connection_state = None - - def _async_disconnected_cleanup(self) -> None: - """Clean up on disconnect.""" - self.services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] - self._is_connected = False - 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._unsubscribe_connection_state() - - def _async_ble_device_disconnected(self) -> None: - """Handle the BLE device disconnecting from the ESP.""" - was_connected = self._is_connected - self._async_disconnected_cleanup() - if was_connected: - _LOGGER.debug( - "%s: %s - %s: BLE device disconnected", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) - self._async_call_bleak_disconnected_callback() - - def _async_esp_disconnected(self) -> None: - """Handle the esp32 client disconnecting from us.""" - _LOGGER.debug( - "%s: %s - %s: ESP device disconnected", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) - self._disconnect_callbacks.remove(self._async_esp_disconnected) - self._async_ble_device_disconnected() - - def _async_call_bleak_disconnected_callback(self) -> None: - """Call the disconnected callback to inform the bleak consumer.""" - if self._disconnected_callback: - self._disconnected_callback() - self._disconnected_callback = None - - def _on_bluetooth_connection_state( - self, - connected_future: asyncio.Future[bool], - connected: bool, - mtu: int, - error: int, - ) -> None: - """Handle a connect or disconnect.""" - _LOGGER.debug( - "%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s", - self._source_name, - self._ble_device.name, - self._ble_device.address, - connected, - mtu, - error, - ) - if connected: - self._is_connected = True - if not self._mtu: - self._mtu = mtu - self._cache.set_gatt_mtu_cache(self._address_as_int, mtu) - else: - self._async_ble_device_disconnected() - - if connected_future.done(): - return - - if error: - try: - ble_connection_error = BLEConnectionError(error) - ble_connection_error_name = ble_connection_error.name - human_error = ESP_CONNECTION_ERROR_DESCRIPTION[ble_connection_error] - except (KeyError, ValueError): - ble_connection_error_name = str(error) - human_error = ESPHOME_GATT_ERRORS.get( - error, f"Unknown error code {error}" - ) - connected_future.set_exception( - BleakError( - f"Error {ble_connection_error_name} while connecting:" - f" {human_error}" - ) - ) - return - - if not connected: - connected_future.set_exception(BleakError("Disconnected")) - return - - _LOGGER.debug( - "%s: %s - %s: connected, registering for disconnected callbacks", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) - self._disconnect_callbacks.append(self._async_esp_disconnected) - connected_future.set_result(connected) - - @api_error_as_bleak_error - async def connect( - self, dangerous_use_bleak_cache: bool = False, **kwargs: Any - ) -> bool: - """Connect to a specified Peripheral. - - **kwargs: - timeout (float): Timeout for required - ``BleakScanner.find_device_by_address`` call. Defaults to 10.0. - - Returns: - Boolean representing connection status. - """ - await self._wait_for_free_connection_slot(CONNECT_FREE_SLOT_TIMEOUT) - cache = self._cache - - self._mtu = cache.get_gatt_mtu_cache(self._address_as_int) - has_cache = bool( - dangerous_use_bleak_cache - and self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING - and cache.get_gatt_services_cache(self._address_as_int) - and self._mtu - ) - connected_future: asyncio.Future[bool] = self._loop.create_future() - - timeout = kwargs.get("timeout", self._timeout) - with self._scanner.connecting(): - try: - self._cancel_connection_state = ( - await self._client.bluetooth_device_connect( - self._address_as_int, - partial(self._on_bluetooth_connection_state, connected_future), - timeout=timeout, - has_cache=has_cache, - feature_flags=self._feature_flags, - address_type=self._address_type, - ) - ) - except asyncio.CancelledError: - if connected_future.done(): - with contextlib.suppress(BleakError): - # If we are cancelled while connecting, - # we need to make sure we await the future - # to avoid a warning about an un-retrieved - # exception. - await connected_future - raise - except Exception as ex: - if connected_future.done(): - with contextlib.suppress(BleakError): - # If the connect call throws an exception, - # we need to make sure we await the future - # to avoid a warning about an un-retrieved - # exception since we prefer to raise the - # exception from the connect call as it - # will be more descriptive. - await connected_future - connected_future.cancel(f"Unhandled exception in connect call: {ex}") - raise - await connected_future - - try: - await self._get_services( - dangerous_use_bleak_cache=dangerous_use_bleak_cache - ) - except asyncio.CancelledError: - # On cancel we must still raise cancelled error - # to avoid blocking the cancellation even if the - # disconnect call fails. - with contextlib.suppress(Exception): - await self._disconnect() - raise - except Exception: - await self._disconnect() - raise - - return True - - @api_error_as_bleak_error - async def disconnect(self) -> bool: - """Disconnect from the peripheral device.""" - return await self._disconnect() - - async def _disconnect(self) -> bool: - await self._client.bluetooth_device_disconnect(self._address_as_int) - self._async_ble_device_disconnected() - await self._wait_for_free_connection_slot(DISCONNECT_TIMEOUT) - return True - - async def _wait_for_free_connection_slot(self, timeout: float) -> None: - """Wait for a free connection slot.""" - bluetooth_device = self._bluetooth_device - if bluetooth_device.ble_connections_free: - return - _LOGGER.debug( - "%s: %s - %s: Out of connection slots, waiting for a free one", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) - async with asyncio.timeout(timeout): - await bluetooth_device.wait_for_ble_connections_free() - - @property - def is_connected(self) -> bool: - """Is Connected.""" - return self._is_connected - - @property - def mtu_size(self) -> int: - """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.""" - if not self._feature_flags & BluetoothProxyFeature.PAIRING: - raise NotImplementedError( - "Pairing is not available in this version ESPHome; " - f"Upgrade the ESPHome version on the {self._device_info.name} device." - ) - response = await self._client.bluetooth_device_pair(self._address_as_int) - if response.paired: - return True - _LOGGER.error( - "Pairing with %s failed due to error: %s", self.address, response.error - ) - return False - - @verify_connected - @api_error_as_bleak_error - async def unpair(self) -> bool: - """Attempt to unpair.""" - if not self._feature_flags & BluetoothProxyFeature.PAIRING: - raise NotImplementedError( - "Unpairing is not available in this version ESPHome; " - f"Upgrade the ESPHome version on the {self._device_info.name} device." - ) - response = await self._client.bluetooth_device_unpair(self._address_as_int) - if response.success: - return True - _LOGGER.error( - "Unpairing with %s failed due to error: %s", self.address, response.error - ) - return False - - @api_error_as_bleak_error - async def get_services( - self, dangerous_use_bleak_cache: bool = False, **kwargs: Any - ) -> BleakGATTServiceCollection: - """Get all services registered for this GATT server. - - Returns: - A :py:class:`bleak.backends.service.BleakGATTServiceCollection` - with this device's services tree. - """ - return await self._get_services( - 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: - """Get all services registered for this GATT server. - - Must only be called from get_services or connected - """ - address_as_int = self._address_as_int - cache = self._cache - # If the connection version >= 3, we must use the cache - # because the esp has already wiped the services list to - # save memory. - if ( - self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING - or dangerous_use_bleak_cache - ) and (cached_services := cache.get_gatt_services_cache(address_as_int)): - _LOGGER.debug( - "%s: %s - %s: Cached services hit", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) - self.services = cached_services - return self.services - _LOGGER.debug( - "%s: %s - %s: Cached services miss", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) - esphome_services = await self._client.bluetooth_gatt_get_services( - address_as_int - ) - _LOGGER.debug( - "%s: %s - %s: Got services: %s", - self._source_name, - self._ble_device.name, - self._ble_device.address, - esphome_services, - ) - max_write_without_response = self.mtu_size - GATT_HEADER_SIZE - services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] - for service in esphome_services.services: - services.add_service(BleakGATTServiceESPHome(service)) - for characteristic in service.characteristics: - services.add_characteristic( - BleakGATTCharacteristicESPHome( - characteristic, - max_write_without_response, - service.uuid, - service.handle, - ) - ) - for descriptor in characteristic.descriptors: - services.add_descriptor( - BleakGATTDescriptorESPHome( - descriptor, - characteristic.uuid, - characteristic.handle, - ) - ) - - if not esphome_services.services: - # If we got no services, we must have disconnected - # or something went wrong on the ESP32's BLE stack. - raise BleakError("Failed to get services from remote esp") - - self.services = services - _LOGGER.debug( - "%s: %s - %s: Cached services saved", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) - cache.set_gatt_services_cache(address_as_int, services) - return services - - def _resolve_characteristic( - self, char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID - ) -> BleakGATTCharacteristic: - """Resolve a characteristic specifier to a BleakGATTCharacteristic object.""" - if (services := self.services) is None: - raise BleakError("Services have not been resolved") - if not isinstance(char_specifier, BleakGATTCharacteristic): - characteristic = services.get_characteristic(char_specifier) - else: - characteristic = char_specifier - if not characteristic: - raise BleakError(f"Characteristic {char_specifier} was not found!") - return characteristic - - @verify_connected - @api_error_as_bleak_error - async def clear_cache(self) -> bool: - """Clear the GATT cache.""" - cache = self._cache - cache.clear_gatt_services_cache(self._address_as_int) - cache.clear_gatt_mtu_cache(self._address_as_int) - if not self._feature_flags & BluetoothProxyFeature.CACHE_CLEARING: - _LOGGER.warning( - "On device cache clear is not available with this ESPHome version; " - "Upgrade the ESPHome version on the device %s; Only memory cache will be cleared", - self._device_info.name, - ) - return True - response = await self._client.bluetooth_device_clear_cache(self._address_as_int) - if response.success: - return True - _LOGGER.error( - "Clear cache failed with %s failed due to error: %s", - self.address, - response.error, - ) - return False - - @verify_connected - @api_error_as_bleak_error - async def read_gatt_char( - self, - char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, - **kwargs: Any, - ) -> bytearray: - """Perform read operation on the specified GATT characteristic. - - Args: - char_specifier (BleakGATTCharacteristic, int, str or UUID): - The characteristic to read from, specified by either integer - handle, UUID or directly by the BleakGATTCharacteristic - object representing it. - **kwargs: Unused - - Returns: - (bytearray) The read data. - """ - 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. - - Args: - handle (int): The handle of the descriptor to read from. - **kwargs: Unused - - Returns: - (bytearray) The read data. - """ - 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, - characteristic: BleakGATTCharacteristic | int | str | uuid.UUID, - data: Buffer, - response: bool = False, - ) -> None: - """Perform a write operation of the specified GATT characteristic. - - Args: - characteristic (BleakGATTCharacteristic, int, str or UUID): - The characteristic to write to, specified by either integer - handle, UUID or directly by the BleakGATTCharacteristic object - representing it. - data (bytes or bytearray): The data to send. - response (bool): If write-with-response operation should be done. - Defaults to `False`. - """ - 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. - - Args: - handle (int): The handle of the descriptor to read from. - data (bytes or bytearray): The data to send. - """ - 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, - characteristic: BleakGATTCharacteristic, - callback: NotifyCallback, - **kwargs: Any, - ) -> None: - """Activate notifications/indications on a characteristic. - - Callbacks must accept two inputs. The first will be a integer handle of the - characteristic generating the data and the second will be a ``bytearray`` - containing the data sent from the connected server. - - .. code-block:: python - def callback(sender: int, data: bytearray): - print(f"{sender}: {data}") - client.start_notify(char_uuid, callback) - - Args: - characteristic (BleakGATTCharacteristic): - The characteristic to activate notifications/indications on a - characteristic, specified by either integer handle, UUID or - directly by the BleakGATTCharacteristic object representing it. - callback (function): The function to be called on notification. - kwargs: Unused. - """ - ble_handle = characteristic.handle - if ble_handle in self._notify_cancels: - raise BleakError( - "Notifications are already enabled on " - f"service:{characteristic.service_uuid} " - f"characteristic:{characteristic.uuid} " - f"handle:{ble_handle}" - ) - if ( - "notify" not in characteristic.properties - and "indicate" not in characteristic.properties - ): - raise BleakError( - f"Characteristic {characteristic.uuid} does not have notify or indicate" - " property set." - ) - - self._notify_cancels[ - ble_handle - ] = await self._client.bluetooth_gatt_start_notify( - self._address_as_int, - ble_handle, - lambda handle, data: callback(data), - ) - - if not self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING: - return - - # For connection v3 we are responsible for enabling notifications - # on the cccd (characteristic client config descriptor) handle since - # the esp32 will not have resolved the characteristic descriptors to - # save memory since doing so can exhaust the memory and cause a soft - # reset - cccd_descriptor = characteristic.get_descriptor(CCCD_UUID) - if not cccd_descriptor: - raise BleakError( - f"Characteristic {characteristic.uuid} does not have a " - "characteristic client config descriptor." - ) - - _LOGGER.debug( - ( - "%s: %s - %s: Writing to CCD descriptor %s for notifications with" - " properties=%s" - ), - self._source_name, - self._ble_device.name, - self._ble_device.address, - cccd_descriptor.handle, - characteristic.properties, - ) - supports_notify = "notify" in characteristic.properties - await self._client.bluetooth_gatt_write_descriptor( - self._address_as_int, - cccd_descriptor.handle, - CCCD_NOTIFY_BYTES if supports_notify else CCCD_INDICATE_BYTES, - wait_for_response=False, - ) - - @verify_connected - @api_error_as_bleak_error - async def stop_notify( - self, - char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, - ) -> None: - """Deactivate notification/indication on a specified characteristic. - - Args: - char_specifier (BleakGATTCharacteristic, int, str or UUID): - The characteristic to deactivate notification/indication on, - specified by either integer handle, UUID or directly by the - BleakGATTCharacteristic object representing it. - """ - 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 - if notify_cancel := self._notify_cancels.pop(characteristic.handle, None): - notify_stop, _ = notify_cancel - await notify_stop() - - def __del__(self) -> None: - """Destructor to make sure the connection state is unsubscribed.""" - if self._cancel_connection_state: - _LOGGER.warning( - ( - "%s: %s - %s: ESPHomeClient bleak client was not properly" - " disconnected before destruction" - ), - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) - if not self._loop.is_closed(): - self._loop.call_soon_threadsafe(self._async_disconnected_cleanup) diff --git a/homeassistant/components/esphome/bluetooth/descriptor.py b/homeassistant/components/esphome/bluetooth/descriptor.py deleted file mode 100644 index 0ba1163974086b..00000000000000 --- a/homeassistant/components/esphome/bluetooth/descriptor.py +++ /dev/null @@ -1,42 +0,0 @@ -"""BleakGATTDescriptorESPHome.""" -from __future__ import annotations - -from aioesphomeapi.model import BluetoothGATTDescriptor -from bleak.backends.descriptor import BleakGATTDescriptor - - -class BleakGATTDescriptorESPHome(BleakGATTDescriptor): - """GATT Descriptor implementation for ESPHome backend.""" - - obj: BluetoothGATTDescriptor - - def __init__( - self, - obj: BluetoothGATTDescriptor, - characteristic_uuid: str, - characteristic_handle: int, - ) -> None: - """Init a BleakGATTDescriptorESPHome.""" - super().__init__(obj) - self.__characteristic_uuid: str = characteristic_uuid - self.__characteristic_handle: int = characteristic_handle - - @property - def characteristic_handle(self) -> int: - """Handle for the characteristic that this descriptor belongs to.""" - return self.__characteristic_handle - - @property - def characteristic_uuid(self) -> str: - """UUID for the characteristic that this descriptor belongs to.""" - return self.__characteristic_uuid - - @property - def uuid(self) -> str: - """UUID for this descriptor.""" - return self.obj.uuid - - @property - def handle(self) -> int: - """Integer handle for this descriptor.""" - return self.obj.handle diff --git a/homeassistant/components/esphome/bluetooth/device.py b/homeassistant/components/esphome/bluetooth/device.py deleted file mode 100644 index c76562a2145a3d..00000000000000 --- a/homeassistant/components/esphome/bluetooth/device.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Bluetooth device models for esphome.""" -from __future__ import annotations - -import asyncio -from dataclasses import dataclass, field -import logging - -from homeassistant.core import callback - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(slots=True) -class ESPHomeBluetoothDevice: - """Bluetooth data for a specific ESPHome device.""" - - name: str - mac_address: str - ble_connections_free: int = 0 - ble_connections_limit: int = 0 - _ble_connection_free_futures: list[asyncio.Future[int]] = field( - default_factory=list - ) - loop: asyncio.AbstractEventLoop = field(default_factory=asyncio.get_running_loop) - - @callback - def async_update_ble_connection_limits(self, free: int, limit: int) -> None: - """Update the BLE connection limits.""" - _LOGGER.debug( - "%s [%s]: BLE connection limits: used=%s free=%s limit=%s", - self.name, - self.mac_address, - limit - free, - free, - limit, - ) - self.ble_connections_free = free - self.ble_connections_limit = limit - if not free: - return - for fut in self._ble_connection_free_futures: - # If wait_for_ble_connections_free gets cancelled, it will - # leave a future in the list. We need to check if it's done - # before setting the result. - if not fut.done(): - fut.set_result(free) - self._ble_connection_free_futures.clear() - - async def wait_for_ble_connections_free(self) -> int: - """Wait until there are free BLE connections.""" - if self.ble_connections_free > 0: - return self.ble_connections_free - fut: asyncio.Future[int] = self.loop.create_future() - self._ble_connection_free_futures.append(fut) - return await fut diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py deleted file mode 100644 index a54e7af59a62ed..00000000000000 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Bluetooth scanner for esphome.""" -from __future__ import annotations - -from aioesphomeapi import BluetoothLEAdvertisement, BluetoothLERawAdvertisement -from bluetooth_data_tools import ( - int_to_bluetooth_address, - parse_advertisement_data_tuple, -) - -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner -from homeassistant.core import callback - - -class ESPHomeScanner(BaseHaRemoteScanner): - """Scanner for esphome.""" - - __slots__ = () - - @callback - def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None: - """Call the registered callback.""" - # The mac address is a uint64, but we need a string - self._async_on_advertisement( - int_to_bluetooth_address(adv.address), - adv.rssi, - adv.name, - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - None, - {"address_type": adv.address_type}, - MONOTONIC_TIME(), - ) - - @callback - def async_on_raw_advertisements( - self, advertisements: list[BluetoothLERawAdvertisement] - ) -> None: - """Call the registered callback.""" - now = MONOTONIC_TIME() - for adv in advertisements: - self._async_on_advertisement( - int_to_bluetooth_address(adv.address), - adv.rssi, - *parse_advertisement_data_tuple((adv.data,)), - {"address_type": adv.address_type}, - now, - ) diff --git a/homeassistant/components/esphome/bluetooth/service.py b/homeassistant/components/esphome/bluetooth/service.py deleted file mode 100644 index 5df7d2bf603026..00000000000000 --- a/homeassistant/components/esphome/bluetooth/service.py +++ /dev/null @@ -1,40 +0,0 @@ -"""BleakGATTServiceESPHome.""" -from __future__ import annotations - -from aioesphomeapi.model import BluetoothGATTService -from bleak.backends.characteristic import BleakGATTCharacteristic -from bleak.backends.service import BleakGATTService - - -class BleakGATTServiceESPHome(BleakGATTService): - """GATT Characteristic implementation for the ESPHome backend.""" - - obj: BluetoothGATTService - - def __init__(self, obj: BluetoothGATTService) -> None: - """Init a BleakGATTServiceESPHome.""" - super().__init__(obj) # type: ignore[no-untyped-call] - self.__characteristics: list[BleakGATTCharacteristic] = [] - self.__handle: int = self.obj.handle - - @property - def handle(self) -> int: - """Integer handle of this service.""" - return self.__handle - - @property - def uuid(self) -> str: - """UUID for this service.""" - return self.obj.uuid - - @property - def characteristics(self) -> list[BleakGATTCharacteristic]: - """List of characteristics for this service.""" - return self.__characteristics - - def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None: - """Add a :py:class:`~BleakGATTCharacteristicESPHome` to the service. - - Should not be used by end user, but rather by `bleak` itself. - """ - self.__characteristics.append(characteristic) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index b34714ff89c2eb..08ed2f1109d997 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -164,11 +164,15 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None: ) 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 @@ def current_temperature(self) -> float | None: """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 @@ def target_temperature_high(self) -> float | None: """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 @@ async def async_set_temperature(self, **kwargs: Any) -> None: 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/dashboard.py b/homeassistant/components/esphome/dashboard.py index 41b0617e630b2c..3d7bfef6ddb59e 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -28,6 +28,8 @@ STORAGE_KEY = "esphome.dashboard" STORAGE_VERSION = 1 +MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") + async def async_setup(hass: HomeAssistant) -> None: """Set up the ESPHome dashboard.""" @@ -177,22 +179,20 @@ def __init__( self.addon_slug = addon_slug self.url = url self.api = ESPHomeDashboardAPI(url, session) - - @property - def supports_update(self) -> bool: - """Return whether the dashboard supports updates.""" - if self.data is None: - raise RuntimeError("Data needs to be loaded first") - - if len(self.data) == 0: - return False - - esphome_version: str = next(iter(self.data.values()))["current_version"] - - # There is no January release - return AwesomeVersion(esphome_version) > AwesomeVersion("2023.1.0") + self.supports_update: bool | None = None async def _async_update_data(self) -> dict: """Fetch device data.""" devices = await self.api.get_devices() - return {dev["name"]: dev for dev in devices["configured"]} + configured_devices = devices["configured"] + + if ( + self.supports_update is None + and configured_devices + and (current_version := configured_devices[0].get("current_version")) + ): + self.supports_update = ( + AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE + ) + + return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index a984d057c0c741..f270196db50994 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -32,12 +32,13 @@ async def async_get_config_entry_diagnostics( if ( config_entry.unique_id - and (scanner := async_scanner_by_source(hass, config_entry.unique_id)) + and (scanner := async_scanner_by_source(hass, config_entry.unique_id.upper())) and (bluetooth_device := entry_data.bluetooth_device) ): diag["bluetooth"] = { "connections_free": bluetooth_device.ble_connections_free, "connections_limit": bluetooth_device.ble_connections_limit, + "available": bluetooth_device.available, "scanner": await scanner.async_diagnostics(), } diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index bf7c5d9c969335..6dae91c4c24bba 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -4,11 +4,12 @@ from dataclasses import dataclass, field from typing import Self, cast +from bleak_esphome.backend.cache import ESPHomeBluetoothCache + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder -from .bluetooth.cache import ESPHomeBluetoothCache from .const import DOMAIN from .entry_data import ESPHomeStorage, RuntimeEntryData diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index dc5a4ff0968d6a..1def6d37e028f0 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -92,7 +92,7 @@ def async_list_entities(infos: list[EntityInfo]) -> None: def esphome_state_property( - func: Callable[[_EntityT], _R] + func: Callable[[_EntityT], _R], ) -> Callable[[_EntityT], _R | None]: """Wrap a state property of an esphome entity. diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index e53200c2e90ba9..d9e5b19974868f 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -35,6 +35,7 @@ build_unique_id, ) from aioesphomeapi.model import ButtonInfo +from bleak_esphome.backend.device import ESPHomeBluetoothDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -43,7 +44,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store -from .bluetooth.device import ESPHomeBluetoothDevice from .const import DOMAIN from .dashboard import async_get_dashboard @@ -107,7 +107,7 @@ class RuntimeEntryData: bluetooth_device: ESPHomeBluetoothDevice | None = None api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list) - disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) + disconnect_callbacks: set[Callable[[], None]] = field(default_factory=set) state_subscriptions: dict[ tuple[type[EntityState], int], Callable[[], None] ] = field(default_factory=dict) @@ -321,7 +321,6 @@ def async_update_state(self, state: EntityState) -> None: 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 @@ def async_update_state(self, state: EntityState) -> None: 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): @@ -427,3 +412,39 @@ async def async_update_listener( if self.original_options == entry.options: return hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) + + @callback + def async_on_disconnect(self) -> None: + """Call when the entry has been disconnected. + + Safe to call multiple times. + """ + self.available = False + if self.bluetooth_device: + self.bluetooth_device.available = False + # Make a copy since calling the disconnect callbacks + # may also try to discard/remove themselves. + for disconnect_cb in self.disconnect_callbacks.copy(): + disconnect_cb() + # Make sure to clear the set to give up the reference + # to it and make sure all the callbacks can be GC'd. + self.disconnect_callbacks.clear() + self.disconnect_callbacks = set() + + @callback + def async_on_connect( + self, device_info: DeviceInfo, api_version: APIVersion + ) -> None: + """Call when the entry has been connected.""" + self.available = True + if self.bluetooth_device: + self.bluetooth_device.available = True + + self.device_info = device_info + self.api_version = api_version + # Reset expected disconnect flag on successful reconnect + # as it will be flipped to False on unexpected disconnect. + # + # We use this to determine if a deep sleep device should + # be marked as unavailable or not. + self.expected_disconnect = True diff --git a/homeassistant/components/esphome/enum_mapper.py b/homeassistant/components/esphome/enum_mapper.py index 566f0bc503b106..fd09f9a05b661a 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 a6ca52d6c1ae9e..08135e1a70258d 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -105,6 +105,10 @@ async def async_set_direction(self, direction: str) -> None: key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self._client.fan_command(key=self._key, preset_mode=preset_mode) + @property @esphome_state_property def is_on(self) -> bool | None: @@ -117,7 +121,8 @@ def percentage(self) -> int | None: """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( @@ -143,6 +148,17 @@ def current_direction(self) -> str | None: """Return the current fan direction.""" return _FAN_DIRECTIONS.from_esphome(self._state.direction) + @property + @esphome_state_property + def preset_mode(self) -> str | None: + """Return the current fan preset mode.""" + return self._state.preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return the supported fan preset modes.""" + return self._static_info.supported_preset_modes + @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" @@ -155,4 +171,6 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None: flags |= FanEntityFeature.SET_SPEED if static_info.supports_direction: flags |= FanEntityFeature.DIRECTION + if static_info.supported_preset_modes: + flags |= FanEntityFeature.PRESET_MODE self._attr_supported_features = flags diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index e170d8b3948fe2..f9fb8b8fb6d57d 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -1,7 +1,8 @@ """Support for ESPHome lights.""" from __future__ import annotations -from typing import Any, cast +from functools import lru_cache +from typing import TYPE_CHECKING, Any, cast from aioesphomeapi import ( APIVersion, @@ -111,6 +112,7 @@ def _mired_to_kelvin(mired_temperature: float) -> int: return round(1000000 / mired_temperature) +@lru_cache def _color_mode_to_ha(mode: int) -> str: """Convert an esphome color mode to a HA color mode constant. @@ -134,20 +136,34 @@ def _color_mode_to_ha(mode: int) -> str: return candidates[-1][0] +@lru_cache def _filter_color_modes( supported: list[int], features: LightColorCapability -) -> list[int]: +) -> tuple[int, ...]: """Filter the given supported color modes. Excluding all values that don't have the requested features. """ - return [mode for mode in supported if (mode & features) == features] + features_value = features.value + return tuple( + mode for mode in supported if (mode & features_value) == features_value + ) + + +@lru_cache +def _least_complex_color_mode(color_modes: tuple[int, ...]) -> int: + """Return the color mode with the least complexity.""" + # popcount with bin() function because it appears + # to be the best way: https://stackoverflow.com/a/9831671 + color_modes_list = list(color_modes) + color_modes_list.sort(key=lambda mode: bin(mode).count("1")) + return color_modes_list[0] class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """A light implementation for ESPHome.""" - _native_supported_color_modes: list[int] + _native_supported_color_modes: tuple[int, ...] _supports_color_mode = False @property @@ -231,10 +247,10 @@ async def async_turn_on(self, **kwargs: Any) -> None: if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None: # Do not use kelvin_to_mired here to prevent precision loss data["color_temperature"] = 1000000.0 / color_temp_k - if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): - color_modes = _filter_color_modes( - color_modes, LightColorCapability.COLOR_TEMPERATURE - ) + if color_temp_modes := _filter_color_modes( + color_modes, LightColorCapability.COLOR_TEMPERATURE + ): + color_modes = color_temp_modes else: color_modes = _filter_color_modes( color_modes, LightColorCapability.COLD_WARM_WHITE @@ -267,10 +283,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: else: # otherwise try the color mode with the least complexity # (fewest capabilities set) - # popcount with bin() function because it appears - # to be the best way: https://stackoverflow.com/a/9831671 - color_modes.sort(key=lambda mode: bin(mode).count("1")) - data["color_mode"] = color_modes[0] + data["color_mode"] = _least_complex_color_mode(color_modes) await self._client.light_command(**data) @@ -294,9 +307,10 @@ def brightness(self) -> int | None: def color_mode(self) -> str | None: """Return the color mode of the light.""" if not self._supports_color_mode: - if not (supported := self.supported_color_modes): - return None - return next(iter(supported)) + supported_color_modes = self.supported_color_modes + if TYPE_CHECKING: + assert supported_color_modes is not None + return next(iter(supported_color_modes)) return _color_mode_to_ha(self._state.color_mode) @@ -374,8 +388,8 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None: super()._on_static_info_update(static_info) static_info = self._static_info self._supports_color_mode = self._api_version >= APIVersion(1, 6) - self._native_supported_color_modes = static_info.supported_color_modes_compat( - self._api_version + self._native_supported_color_modes = tuple( + static_info.supported_color_modes_compat(self._api_version) ) flags = LightEntityFeature.FLASH diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index d2eca7d39f9240..f0263bdc48b3a4 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -1,6 +1,9 @@ """Manager for esphome devices.""" from __future__ import annotations +import asyncio +from collections.abc import Coroutine +from functools import partial import logging from typing import TYPE_CHECKING, Any, NamedTuple @@ -9,6 +12,7 @@ APIConnectionError, APIVersion, DeviceInfo as EsphomeDeviceInfo, + EntityInfo, HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, @@ -24,8 +28,20 @@ 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 @@ -187,14 +203,19 @@ def async_on_service_call(self, service: HomeassistantServiceCall) -> None: template.render_complex(data_template, service.variables) ) except TemplateError as ex: - _LOGGER.error("Error rendering data template for %s: %s", self.host, ex) + _LOGGER.error( + "Error rendering data template %s for %s: %s", + service.data_template, + self.host, + ex, + ) return if service.is_event: device_id = self.device_id # ESPHome uses service call packet for both events and service calls # Ensure the user can only send events of form 'esphome.xyz' - if domain != "esphome": + if domain != DOMAIN: _LOGGER.error( "Can only generate events under esphome domain! (%s)", self.host ) @@ -294,7 +315,7 @@ async def send_home_assistant_state_event( event.data["entity_id"], attribute, new_state ) - self.entry_data.disconnect_callbacks.append( + self.entry_data.disconnect_callbacks.add( async_track_state_change_event( hass, [entity_id], send_home_assistant_state_event ) @@ -371,13 +392,20 @@ async def on_connect(self) -> None: 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 # @@ -425,55 +453,60 @@ async def on_connect(self) -> None: entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name} ) - entry_data.device_info = device_info - assert cli.api_version is not None - entry_data.api_version = cli.api_version - entry_data.available = True - # Reset expected disconnect flag on successful reconnect - # as it will be flipped to False on unexpected disconnect. - # - # We use this to determine if a deep sleep device should - # be marked as unavailable or not. - entry_data.expected_disconnect = True + api_version = cli.api_version + assert api_version is not None, "API version must be set" + entry_data.async_on_connect(device_info, api_version) + if device_info.name: reconnect_logic.name = device_info.name - if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): - entry_data.disconnect_callbacks.append( - await 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) + await 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(api_version): + setup_coros_with_disconnect_callbacks.append( + async_connect_scanner( + hass, entry_data, cli, device_info, 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.append( - 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, 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.""" @@ -487,10 +520,7 @@ async def on_disconnect(self, expected_disconnect: bool) -> None: host, expected_disconnect, ) - for disconnect_cb in entry_data.disconnect_callbacks: - disconnect_cb() - entry_data.disconnect_callbacks = [] - entry_data.available = False + entry_data.async_on_disconnect() entry_data.expected_disconnect = expected_disconnect # Mark state as stale so that we will always dispatch # the next state update of that type when the device reconnects @@ -518,6 +548,11 @@ async def on_connect_error(self, err: Exception) -> None: ): 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 @@ -534,6 +569,11 @@ async def async_start(self) -> None: 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, @@ -550,7 +590,7 @@ async def async_start(self) -> None: await entry_data.async_update_static_infos( hass, entry, infos, entry.unique_id.upper() ) - await _setup_services(hass, entry_data, services) + _setup_services(hass, entry_data, services) if entry_data.device_info is not None and entry_data.device_info.name: reconnect_logic.name = entry_data.device_info.name @@ -672,12 +712,27 @@ class ServiceMetadata(NamedTuple): } -async def _register_service( - hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService +async def execute_service( + entry_data: RuntimeEntryData, service: UserService, call: ServiceCall +) -> None: + """Execute a service on a node.""" + await entry_data.client.execute_service(service, call.data) + + +def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str: + """Build a service name for a node.""" + return f"{device_info.name.replace('-', '_')}_{service.name}" + + +@callback +def _async_register_service( + hass: HomeAssistant, + entry_data: RuntimeEntryData, + device_info: EsphomeDeviceInfo, + service: UserService, ) -> None: - if entry_data.device_info is None: - raise ValueError("Device Info needs to be fetched first") - service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}" + """Register a service on a node.""" + service_name = build_service_name(device_info, service) schema = {} fields = {} @@ -700,33 +755,36 @@ async def _register_service( "selector": metadata.selector, } - async def execute_service(call: ServiceCall) -> None: - await entry_data.client.execute_service(service, call.data) - hass.services.async_register( - DOMAIN, service_name, execute_service, vol.Schema(schema) + DOMAIN, + service_name, + partial(execute_service, entry_data, service), + vol.Schema(schema), + ) + async_set_service_schema( + hass, + DOMAIN, + service_name, + { + "description": ( + f"Calls the service {service.name} of the node {device_info.name}" + ), + "fields": fields, + }, ) - - service_desc = { - "description": ( - f"Calls the service {service.name} of the node" - f" {entry_data.device_info.name}" - ), - "fields": fields, - } - - async_set_service_schema(hass, DOMAIN, service_name, service_desc) -async def _setup_services( +@callback +def _setup_services( hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService] ) -> None: - if entry_data.device_info is None: + device_info = entry_data.device_info + if device_info is None: # Can happen if device has never connected or .storage cleared return old_services = entry_data.services.copy() - to_unregister = [] - to_register = [] + to_unregister: list[UserService] = [] + to_register: list[UserService] = [] for service in services: if service.key in old_services: # Already exists @@ -744,21 +802,18 @@ async def _setup_services( entry_data.services = {serv.key: serv for serv in services} for service in to_unregister: - service_name = f"{entry_data.device_info.name}_{service.name}" + service_name = build_service_name(device_info, service) hass.services.async_remove(DOMAIN, service_name) for service in to_register: - await _register_service(hass, entry_data, service) + _async_register_service(hass, entry_data, device_info, service) async def cleanup_instance(hass: HomeAssistant, entry: ConfigEntry) -> RuntimeEntryData: """Cleanup the esphome client if it exists.""" domain_data = DomainData.get(hass) data = domain_data.pop_entry_data(entry) - data.available = False - for disconnect_cb in data.disconnect_callbacks: - disconnect_cb() - data.disconnect_callbacks = [] + data.async_on_disconnect() for cleanup_callback in data.cleanup_callbacks: cleanup_callback() await data.async_cleanup() diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 3b5a2050cb8720..e3437e5aa73bce 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -13,12 +13,11 @@ "documentation": "https://www.home-assistant.io/integrations/esphome", "integration_type": "device", "iot_class": "local_push", - "loggers": ["aioesphomeapi", "noiseprotocol"], + "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ - "async-interrupt==1.1.1", - "aioesphomeapi==18.4.0", - "bluetooth-data-tools==1.14.0", - "esphome-dashboard-api==1.2.3" + "aioesphomeapi==21.0.1", + "esphome-dashboard-api==1.2.3", + "bleak-esphome==0.4.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 859b28a53b53c8..ea052522e765b6 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -3,7 +3,7 @@ import asyncio import logging -from typing import Any, cast +from typing import Any from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo @@ -27,6 +27,7 @@ KEY_UPDATE_LOCK = "esphome_update_lock" +NO_FEATURES = UpdateEntityFeature(0) _LOGGER = logging.getLogger(__name__) @@ -76,6 +77,7 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_title = "ESPHome" _attr_name = "Firmware" + _attr_release_url = "https://esphome.io/changelog/" def __init__( self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboard @@ -90,15 +92,36 @@ def __init__( (dr.CONNECTION_NETWORK_MAC, entry_data.device_info.mac_address) } ) + self._update_attrs() + @callback + def _update_attrs(self) -> None: + """Update the supported features.""" # If the device has deep sleep, we can't assume we can install updates # as the ESP will not be connectable (by design). + coordinator = self.coordinator + device_info = self._device_info + # Install support can change at run time if ( coordinator.last_update_success and coordinator.supports_update - and not self._device_info.has_deep_sleep + and not device_info.has_deep_sleep ): self._attr_supported_features = UpdateEntityFeature.INSTALL + else: + self._attr_supported_features = NO_FEATURES + self._attr_installed_version = device_info.esphome_version + device = coordinator.data.get(device_info.name) + if device is None: + self._attr_latest_version = None + else: + self._attr_latest_version = device["current_version"] + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs() + super()._handle_coordinator_update() @property def _device_info(self) -> ESPHomeDeviceInfo: @@ -119,44 +142,29 @@ def available(self) -> bool: or self._device_info.has_deep_sleep ) - @property - def installed_version(self) -> str | None: - """Version currently installed and in use.""" - return self._device_info.esphome_version - - @property - def latest_version(self) -> str | None: - """Latest version available for install.""" - device = self.coordinator.data.get(self._device_info.name) - if device is None: - return None - return cast(str, device["current_version"]) - - @property - def release_url(self) -> str | None: - """URL to the full release notes of the latest version available.""" - return "https://esphome.io/changelog/" - @callback - def _async_static_info_updated(self, _: list[EntityInfo]) -> None: - """Handle static info update.""" + def _handle_device_update(self, static_info: EntityInfo | None = None) -> None: + """Handle updated data from the device.""" + self._update_attrs() self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Handle entity added to Home Assistant.""" await super().async_added_to_hass() + hass = self.hass + entry_data = self._entry_data self.async_on_remove( async_dispatcher_connect( - self.hass, - self._entry_data.signal_static_info_updated, - self._async_static_info_updated, + hass, + entry_data.signal_static_info_updated, + self._handle_device_update, ) ) self.async_on_remove( async_dispatcher_connect( - self.hass, - self._entry_data.signal_device_updated, - self.async_write_ha_state, + hass, + entry_data.signal_device_updated, + self._handle_device_update, ) ) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index bb62d495076fe4..de6b521d980823 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -186,16 +186,22 @@ def _event_callback(self, event: PipelineEvent) -> None: 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} - - if self.device_info.voice_assistant_version >= 2: - media_id = event.data["tts_output"]["media_id"] - self._tts_task = self.hass.async_create_background_task( - self._send_tts(media_id), "esphome_voice_assistant_tts" - ) + 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 = 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 @@ -301,10 +307,6 @@ async def _send_tts(self, media_id: str) -> None: if self.transport is None: return - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {} - ) - extension, data = await tts.async_get_media_source_audio( self.hass, media_id, @@ -331,11 +333,17 @@ async def _send_tts(self, media_id: str) -> None: audio_bytes = wav_file.readframes(wav_file.getnframes()) - _LOGGER.debug("Sending %d bytes of audio", len(audio_bytes)) + 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 diff --git a/homeassistant/components/eufylife_ble/__init__.py b/homeassistant/components/eufylife_ble/__init__.py index 49370c2efcf84b..f407e86a289d81 100644 --- a/homeassistant/components/eufylife_ble/__init__.py +++ b/homeassistant/components/eufylife_ble/__init__.py @@ -6,10 +6,10 @@ from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_MODEL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from .const import CONF_MODEL, DOMAIN +from .const import DOMAIN from .models import EufyLifeData PLATFORMS: list[Platform] = [Platform.SENSOR] diff --git a/homeassistant/components/eufylife_ble/config_flow.py b/homeassistant/components/eufylife_ble/config_flow.py index 9e1ff4af7a8a95..e3a1a301f2548c 100644 --- a/homeassistant/components/eufylife_ble/config_flow.py +++ b/homeassistant/components/eufylife_ble/config_flow.py @@ -11,10 +11,10 @@ async_discovered_service_info, ) from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_ADDRESS +from homeassistant.const import CONF_ADDRESS, CONF_MODEL from homeassistant.data_entry_flow import FlowResult -from .const import CONF_MODEL, DOMAIN +from .const import DOMAIN class EufyLifeConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/eufylife_ble/const.py b/homeassistant/components/eufylife_ble/const.py index dac0afc910956e..e6beb34aaff98a 100644 --- a/homeassistant/components/eufylife_ble/const.py +++ b/homeassistant/components/eufylife_ble/const.py @@ -1,5 +1,3 @@ """Constants for the EufyLife integration.""" DOMAIN = "eufylife_ble" - -CONF_MODEL = "model" diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index d960867097276f..b05c3a6f3a584d 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from enum import StrEnum import logging -from typing import Any, Self, final +from typing import TYPE_CHECKING, Any, Self, final from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,6 +21,12 @@ from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -71,8 +77,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class EventEntityDescription(EntityDescription): +class EventEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes event entities.""" device_class: EventDeviceClass | None = None @@ -102,7 +107,13 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: return None -class EventEntity(RestoreEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "event_types", +} + + +class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of an Event entity.""" _entity_component_unrecorded_attributes = frozenset({ATTR_EVENT_TYPES}) @@ -116,7 +127,7 @@ class EventEntity(RestoreEntity): __last_event_type: str | None = None __last_event_attributes: dict[str, Any] | None = None - @property + @cached_property def device_class(self) -> EventDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -125,7 +136,7 @@ def device_class(self) -> EventDeviceClass | None: return self.entity_description.device_class return None - @property + @cached_property def event_types(self) -> list[str]: """Return a list of possible events.""" if hasattr(self, "_attr_event_types"): diff --git a/homeassistant/components/evil_genius_labs/strings.json b/homeassistant/components/evil_genius_labs/strings.json index 790e9a69c7fd59..123d164444db8a 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/evil_genius_labs/util.py b/homeassistant/components/evil_genius_labs/util.py index b0e01c1f3291df..eb2caf59d9d19c 100644 --- a/homeassistant/components/evil_genius_labs/util.py +++ b/homeassistant/components/evil_genius_labs/util.py @@ -13,7 +13,7 @@ def update_when_done( - func: Callable[Concatenate[_EvilGeniusEntityT, _P], Awaitable[_R]] + func: Callable[Concatenate[_EvilGeniusEntityT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_EvilGeniusEntityT, _P], Coroutine[Any, Any, _R]]: """Decorate function to trigger update when function is done.""" diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index c26310bf61c340..06712a83b6afda 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -4,15 +4,32 @@ """ from __future__ import annotations -from datetime import datetime as dt, timedelta +from collections.abc import Awaitable +from datetime import datetime, timedelta from http import HTTPStatus import logging import re from typing import Any -import evohomeasync -import evohomeasync2 -import voluptuous as vol +import evohomeasync as ev1 +from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP +import evohomeasync2 as evo +from evohomeasync2.schema.const import ( + SZ_ALLOWED_SYSTEM_MODES, + SZ_AUTO_WITH_RESET, + SZ_CAN_BE_TEMPORARY, + SZ_HEAT_SETPOINT, + SZ_LOCATION_INFO, + SZ_SETPOINT_STATUS, + SZ_STATE_STATUS, + SZ_SYSTEM_MODE, + SZ_SYSTEM_MODE_STATUS, + SZ_TIME_UNTIL, + SZ_TIME_ZONE, + SZ_TIMING_MODE, + SZ_UNTIL, +) +import voluptuous as vol # type: ignore[import-untyped] from homeassistant.const import ( ATTR_ENTITY_ID, @@ -96,15 +113,15 @@ # system mode schemas are built dynamically, below -def _dt_local_to_aware(dt_naive: dt) -> dt: - dt_aware = dt_util.now() + (dt_naive - dt.now()) +def _dt_local_to_aware(dt_naive: datetime) -> datetime: + dt_aware = dt_util.now() + (dt_naive - datetime.now()) if dt_aware.microsecond >= 500000: dt_aware += timedelta(seconds=1) return dt_aware.replace(microsecond=0) -def _dt_aware_to_naive(dt_aware: dt) -> dt: - dt_naive = dt.now() + (dt_aware - dt_util.now()) +def _dt_aware_to_naive(dt_aware: datetime) -> datetime: + dt_naive = datetime.now() + (dt_aware - dt_util.now()) if dt_naive.microsecond >= 500000: dt_naive += timedelta(seconds=1) return dt_naive.replace(microsecond=0) @@ -124,10 +141,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 { @@ -138,12 +158,12 @@ def convert_key(key: str) -> str: } -def _handle_exception(err) -> None: +def _handle_exception(err: evo.RequestFailed) -> None: """Return False if the exception can't be ignored.""" try: raise err - except evohomeasync2.AuthenticationFailed: + except evo.AuthenticationFailed: _LOGGER.error( ( "Failed to authenticate with the vendor's server. Check your username" @@ -154,7 +174,7 @@ def _handle_exception(err) -> None: err, ) - except evohomeasync2.RequestFailed: + except evo.RequestFailed: if err.status is None: _LOGGER.warning( ( @@ -187,14 +207,14 @@ def _handle_exception(err) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Create a (EMEA/EU-based) Honeywell TCC system.""" - async def load_auth_tokens(store) -> tuple[dict, dict | None]: + async def load_auth_tokens(store: Store) -> tuple[dict, dict | None]: app_storage = await store.async_load() tokens = dict(app_storage or {}) if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]: # any tokens won't be valid, and store might be corrupt await store.async_save({}) - return ({}, None) + return ({}, {}) # evohomeasync2 requires naive/local datetimes as strings if tokens.get(ACCESS_TOKEN_EXPIRES) is not None and ( @@ -202,13 +222,13 @@ async def load_auth_tokens(store) -> tuple[dict, dict | None]: ): tokens[ACCESS_TOKEN_EXPIRES] = _dt_aware_to_naive(expires) - user_data = tokens.pop(USER_DATA, None) + user_data = tokens.pop(USER_DATA, {}) return (tokens, user_data) store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY) tokens, user_data = await load_auth_tokens(store) - client_v2 = evohomeasync2.EvohomeClient( + client_v2 = evo.EvohomeClient( config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], **tokens, @@ -217,7 +237,7 @@ async def load_auth_tokens(store) -> tuple[dict, dict | None]: try: await client_v2.login() - except evohomeasync2.AuthenticationFailed as err: + except evo.AuthenticationFailed as err: _handle_exception(err) return False finally: @@ -240,17 +260,19 @@ async def load_auth_tokens(store) -> tuple[dict, dict | None]: if _LOGGER.isEnabledFor(logging.DEBUG): _config: dict[str, Any] = { - "locationInfo": {"timeZone": None}, + SZ_LOCATION_INFO: {SZ_TIME_ZONE: None}, GWS: [{TCS: None}], } - _config["locationInfo"]["timeZone"] = loc_config["locationInfo"]["timeZone"] + _config[SZ_LOCATION_INFO][SZ_TIME_ZONE] = loc_config[SZ_LOCATION_INFO][ + SZ_TIME_ZONE + ] _config[GWS][0][TCS] = loc_config[GWS][0][TCS] _LOGGER.debug("Config = %s", _config) - client_v1 = evohomeasync.EvohomeClient( + client_v1 = ev1.EvohomeClient( client_v2.username, client_v2.password, - user_data=user_data, + session_id=user_data.get(SZ_SESSION_ID) if user_data else None, # STORAGE_VER 1 session=async_get_clientsession(hass), ) @@ -280,7 +302,7 @@ async def load_auth_tokens(store) -> tuple[dict, dict | None]: @callback -def setup_service_functions(hass: HomeAssistant, broker): +def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: """Set up the service handlers for the system/zone operating modes. Not all Honeywell TCC-compatible systems support all operating modes. In addition, @@ -330,25 +352,25 @@ async def set_zone_override(call: ServiceCall) -> None: hass.services.async_register(DOMAIN, SVC_REFRESH_SYSTEM, force_refresh) # Enumerate which operating modes are supported by this system - modes = broker.config["allowedSystemModes"] + modes = broker.config[SZ_ALLOWED_SYSTEM_MODES] # Not all systems support "AutoWithReset": register this handler only if required - if [m["systemMode"] for m in modes if m["systemMode"] == "AutoWithReset"]: + if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]: hass.services.async_register(DOMAIN, SVC_RESET_SYSTEM, set_system_mode) system_mode_schemas = [] - modes = [m for m in modes if m["systemMode"] != "AutoWithReset"] + modes = [m for m in modes if m[SZ_SYSTEM_MODE] != SZ_AUTO_WITH_RESET] # Permanent-only modes will use this schema - perm_modes = [m["systemMode"] for m in modes if not m["canBeTemporary"]] + perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] if perm_modes: # any of: "Auto", "HeatingOff": permanent only schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)}) system_mode_schemas.append(schema) - modes = [m for m in modes if m["canBeTemporary"]] + modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] # These modes are set for a number of hours (or indefinitely): use this schema - temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Duration"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Duration"] if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours schema = vol.Schema( { @@ -362,7 +384,7 @@ async def set_zone_override(call: ServiceCall) -> None: system_mode_schemas.append(schema) # These modes are set for a number of days (or indefinitely): use this schema - temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Period"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Period"] if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days schema = vol.Schema( { @@ -380,7 +402,7 @@ async def set_zone_override(call: ServiceCall) -> None: DOMAIN, SVC_SET_SYSTEM_MODE, set_system_mode, - schema=vol.Any(*system_mode_schemas), + schema=vol.Schema(vol.Any(*system_mode_schemas)), ) # The zone modes are consistent across all systems and use the same schema @@ -404,8 +426,8 @@ class EvoBroker: def __init__( self, hass: HomeAssistant, - client: evohomeasync2.EvohomeClient, - client_v1: evohomeasync.EvohomeClient | None, + client: evo.EvohomeClient, + client_v1: ev1.EvohomeClient | None, store: Store[dict[str, Any]], params: ConfigType, ) -> None: @@ -417,12 +439,12 @@ def __init__( self.params = params loc_idx = params[CONF_LOCATION_IDX] + self._location: evo.Location = client.locations[loc_idx] + self.config = client.installation_info[loc_idx][GWS][0][TCS][0] - self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0] - self.tcs_utc_offset = timedelta( - minutes=client.locations[loc_idx].timeZone[UTC_OFFSET] - ) - self.temps: dict[str, Any] | None = {} + self.tcs: evo.ControlSystem = self._location._gateways[0]._control_systems[0] + self.tcs_utc_offset = timedelta(minutes=self._location.timeZone[UTC_OFFSET]) + self.temps: dict[str, float | None] = {} async def save_auth_tokens(self) -> None: """Save access tokens and session IDs to the store for later use.""" @@ -438,45 +460,48 @@ async def save_auth_tokens(self) -> None: ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), } - if self.client_v1 and self.client_v1.user_data: - user_id = self.client_v1.user_data["userInfo"]["userID"] # type: ignore[index] + if self.client_v1: app_storage[USER_DATA] = { # type: ignore[assignment] - "userInfo": {"userID": user_id}, - "sessionId": self.client_v1.user_data["sessionId"], - } + SZ_SESSION_ID: self.client_v1.broker.session_id, + } # this is the schema for STORAGE_VER == 1 else: - app_storage[USER_DATA] = None + app_storage[USER_DATA] = {} # type: ignore[assignment] await self._store.async_save(app_storage) - async def call_client_api(self, api_function, update_state=True) -> Any: + async def call_client_api( + self, + api_function: Awaitable[dict[str, Any] | None], + update_state: bool = True, + ) -> dict[str, Any] | None: """Call a client API and update the broker state if required.""" try: result = await api_function - except evohomeasync2.EvohomeError as err: + except evo.RequestFailed as err: _handle_exception(err) - return + return None if update_state: # wait a moment for system to quiesce before updating state async_call_later(self.hass, 1, self._update_v2_api_state) return result - async def _update_v1_api_temps(self, *args, **kwargs) -> None: + async def _update_v1_api_temps(self) -> None: """Get the latest high-precision temperatures of the default Location.""" - assert self.client_v1 + assert self.client_v1 is not None # mypy check - def get_session_id(client_v1) -> str | None: + def get_session_id(client_v1: ev1.EvohomeClient) -> str | None: user_data = client_v1.user_data if client_v1 else None - return user_data.get("sessionId") if user_data else None + return user_data.get(SZ_SESSION_ID) if user_data else None # type: ignore[return-value] session_id = get_session_id(self.client_v1) + self.temps = {} # these are now stale, will fall back to v2 temps try: - temps = list(await self.client_v1.temperatures(force_refresh=True)) + temps = await self.client_v1.get_temperatures() - except evohomeasync.InvalidSchema as exc: + except ev1.InvalidSchema as err: _LOGGER.warning( ( "Unable to obtain high-precision temperatures. " @@ -484,11 +509,11 @@ def get_session_id(client_v1) -> str | None: "so the high-precision feature will be disabled until next restart." "Message is: %s" ), - exc, + err, ) - self.temps = self.client_v1 = None + self.client_v1 = None - except evohomeasync.EvohomeError as exc: + except ev1.RequestFailed as err: _LOGGER.warning( ( "Unable to obtain the latest high-precision temperatures. " @@ -496,48 +521,44 @@ def get_session_id(client_v1) -> str | None: "Proceeding without high-precision temperatures for now. " "Message is: %s" ), - exc, + err, ) - self.temps = None # these are now stale, will fall back to v2 temps else: - if ( - str(self.client_v1.location_id) - != self.client.locations[self.params[CONF_LOCATION_IDX]].locationId - ): + if str(self.client_v1.location_id) != self._location.locationId: _LOGGER.warning( "The v2 API's configured location doesn't match " "the v1 API's default location (there is more than one location), " "so the high-precision feature will be disabled until next restart" ) - self.temps = self.client_v1 = None + self.client_v1 = None else: - self.temps = {str(i["id"]): i["temp"] for i in temps} + self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps} finally: - if session_id != get_session_id(self.client_v1): + if self.client_v1 and session_id != self.client_v1.broker.session_id: await self.save_auth_tokens() _LOGGER.debug("Temperatures = %s", self.temps) - async def _update_v2_api_state(self, *args, **kwargs) -> None: + async def _update_v2_api_state(self, *args: Any) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" - access_token = self.client.access_token - loc_idx = self.params[CONF_LOCATION_IDX] + access_token = self.client.access_token # maybe receive a new token? + try: - status = await self.client.locations[loc_idx].refresh_status() - except evohomeasync2.EvohomeError as err: + status = await self._location.refresh_status() + except evo.RequestFailed as err: _handle_exception(err) else: async_dispatcher_send(self.hass, DOMAIN) _LOGGER.debug("Status = %s", status) + finally: + if access_token != self.client.access_token: + await self.save_auth_tokens() - if access_token != self.client.access_token: - await self.save_auth_tokens() - - async def async_update(self, *args, **kwargs) -> None: + async def async_update(self, *args: Any) -> None: """Get the latest state data of an entire Honeywell TCC Location. This includes state data for a Controller and all its child devices, such as the @@ -559,7 +580,11 @@ class EvoDevice(Entity): _attr_should_poll = False - def __init__(self, evo_broker, evo_device) -> None: + def __init__( + self, + evo_broker: EvoBroker, + evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, + ) -> None: """Initialize the evohome entity.""" self._evo_device = evo_device self._evo_broker = evo_broker @@ -591,12 +616,12 @@ async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> No def extra_state_attributes(self) -> dict[str, Any]: """Return the evohome-specific state attributes.""" status = self._device_state_attrs - if "systemModeStatus" in status: - convert_until(status["systemModeStatus"], "timeUntil") - if "setpointStatus" in status: - convert_until(status["setpointStatus"], "until") - if "stateStatus" in status: - convert_until(status["stateStatus"], "until") + if SZ_SYSTEM_MODE_STATUS in status: + convert_until(status[SZ_SYSTEM_MODE_STATUS], SZ_TIME_UNTIL) + if SZ_SETPOINT_STATUS in status: + convert_until(status[SZ_SETPOINT_STATUS], SZ_UNTIL) + if SZ_STATE_STATUS in status: + convert_until(status[SZ_STATE_STATUS], SZ_UNTIL) return {"status": convert_dict(status)} @@ -611,27 +636,26 @@ class EvoChild(EvoDevice): This includes (up to 12) Heating Zones and (optionally) a DHW controller. """ - def __init__(self, evo_broker, evo_device) -> None: + _evo_id: str # mypy hint + + def __init__( + self, evo_broker: EvoBroker, evo_device: evo.HotWater | evo.Zone + ) -> None: """Initialize a evohome Controller (hub).""" super().__init__(evo_broker, evo_device) + self._schedule: dict[str, Any] = {} self._setpoints: dict[str, Any] = {} @property def current_temperature(self) -> float | None: """Return the current temperature of a Zone.""" - 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] + assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check - if self._evo_device.temperatureStatus["isAvailable"]: - return self._evo_device.temperatureStatus["temperature"] - - return None + if self._evo_broker.temps.get(self._evo_id) is not None: + return self._evo_broker.temps[self._evo_id] + return self._evo_device.temperature @property def setpoints(self) -> dict[str, Any]: @@ -640,7 +664,7 @@ def setpoints(self) -> dict[str, Any]: Only Zones & DHW controllers (but not the TCS) can have schedules. """ - def _dt_evo_to_aware(dt_naive: dt, utc_offset: timedelta) -> dt: + def _dt_evo_to_aware(dt_naive: datetime, utc_offset: timedelta) -> datetime: dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset return dt_util.as_local(dt_aware) @@ -676,14 +700,14 @@ def _dt_evo_to_aware(dt_naive: dt, utc_offset: timedelta) -> dt: switchpoint_time_of_day = dt_util.parse_datetime( f"{sp_date}T{switchpoint['TimeOfDay']}" ) - assert switchpoint_time_of_day + assert switchpoint_time_of_day is not None # mypy check dt_aware = _dt_evo_to_aware( switchpoint_time_of_day, self._evo_broker.tcs_utc_offset ) self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat() try: - self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"] + self._setpoints[f"{key}_sp_temp"] = switchpoint[SZ_HEAT_SETPOINT] except KeyError: self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"] @@ -698,7 +722,10 @@ def _dt_evo_to_aware(dt_naive: dt, utc_offset: timedelta) -> dt: async def _update_schedule(self) -> None: """Get the latest schedule, if any.""" - self._schedule = await self._evo_broker.call_client_api( + + assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check + + self._schedule = await self._evo_broker.call_client_api( # type: ignore[assignment] self._evo_device.get_schedule(), update_state=False ) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index fb608262a7dfd9..1e092d7fc34a73 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -1,9 +1,24 @@ """Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems.""" from __future__ import annotations -from datetime import datetime as dt +from datetime import datetime, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any + +import evohomeasync2 as evo +from evohomeasync2.schema.const import ( + SZ_ACTIVE_FAULTS, + SZ_ALLOWED_SYSTEM_MODES, + SZ_SETPOINT_STATUS, + SZ_SYSTEM_ID, + SZ_SYSTEM_MODE, + SZ_SYSTEM_MODE_STATUS, + SZ_TEMPERATURE_STATUS, + SZ_UNTIL, + SZ_ZONE_ID, + ZoneModelType, + ZoneType, +) from homeassistant.components.climate import ( PRESET_AWAY, @@ -47,6 +62,10 @@ EVO_TEMPOVER, ) +if TYPE_CHECKING: + from . import EvoBroker + + _LOGGER = logging.getLogger(__name__) PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW @@ -71,8 +90,13 @@ } HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} -STATE_ATTRS_TCS = ["systemId", "activeFaults", "systemModeStatus"] -STATE_ATTRS_ZONES = ["zoneId", "activeFaults", "setpointStatus", "temperatureStatus"] +STATE_ATTRS_TCS = [SZ_SYSTEM_ID, SZ_ACTIVE_FAULTS, SZ_SYSTEM_MODE_STATUS] +STATE_ATTRS_ZONES = [ + SZ_ZONE_ID, + SZ_ACTIVE_FAULTS, + SZ_SETPOINT_STATUS, + SZ_TEMPERATURE_STATUS, +] async def async_setup_platform( @@ -85,7 +109,7 @@ async def async_setup_platform( if discovery_info is None: return - broker = hass.data[DOMAIN]["broker"] + broker: EvoBroker = hass.data[DOMAIN]["broker"] _LOGGER.debug( "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", @@ -98,7 +122,10 @@ async def async_setup_platform( entities: list[EvoClimateEntity] = [EvoController(broker, broker.tcs)] for zone in broker.tcs.zones.values(): - if zone.modelType == "HeatingZone" or zone.zoneType == "Thermostat": + if ( + zone.modelType == ZoneModelType.HEATING_ZONE + or zone.zoneType == ZoneType.THERMOSTAT + ): _LOGGER.debug( "Adding: %s (%s), id=%s, name=%s", zone.zoneType, @@ -141,9 +168,13 @@ class EvoZone(EvoChild, EvoClimateEntity): _attr_preset_modes = list(HA_PRESET_TO_EVO) - def __init__(self, evo_broker, evo_device) -> None: + _evo_device: evo.Zone # mypy hint + + def __init__(self, evo_broker: EvoBroker, evo_device: evo.Zone) -> None: """Initialize a Honeywell TCC Zone.""" + super().__init__(evo_broker, evo_device) + self._evo_id = evo_device.zoneId if evo_device.modelType.startswith("VisionProWifi"): # this system does not have a distinct ID for the zone @@ -174,7 +205,7 @@ async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> No temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp) if ATTR_DURATION_UNTIL in data: - duration = data[ATTR_DURATION_UNTIL] + duration: timedelta = data[ATTR_DURATION_UNTIL] if duration.total_seconds() == 0: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) @@ -189,24 +220,29 @@ async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> No ) @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return the current operating mode of a Zone.""" - if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): + if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): return HVACMode.AUTO - is_off = self.target_temperature <= self.min_temp - return HVACMode.OFF if is_off else HVACMode.HEAT + if self.target_temperature is None: + return None + if self.target_temperature <= self.min_temp: + return HVACMode.OFF + return HVACMode.HEAT @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the target temperature of a Zone.""" - return self._evo_device.setpointStatus["targetHeatTemperature"] + return self._evo_device.target_heat_temperature @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): - return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) - return EVO_PRESET_TO_HA.get(self._evo_device.setpointStatus["setpointMode"]) + if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): + return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) + if self._evo_device.mode is None: + return None + return EVO_PRESET_TO_HA.get(self._evo_device.mode) @property def min_temp(self) -> float: @@ -214,7 +250,7 @@ def min_temp(self) -> float: The default is 5, but is user-configurable within 5-35 (in Celsius). """ - return self._evo_device.setpointCapabilities["minHeatSetpoint"] + return self._evo_device.min_heat_setpoint @property def max_temp(self) -> float: @@ -222,18 +258,23 @@ def max_temp(self) -> float: The default is 35, but is user-configurable within 5-35 (in Celsius). """ - return self._evo_device.setpointCapabilities["maxHeatSetpoint"] + return self._evo_device.max_heat_setpoint async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" + + assert self._evo_device.setpointStatus is not None # mypy check + temperature = kwargs["temperature"] if (until := kwargs.get("until")) is None: - if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: + if self._evo_device.mode == EVO_FOLLOW: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER: - until = dt_util.parse_datetime(self._evo_device.setpointStatus["until"]) + elif self._evo_device.mode == EVO_TEMPOVER: + until = dt_util.parse_datetime( + self._evo_device.setpointStatus[SZ_UNTIL] + ) until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( @@ -272,14 +313,15 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return - temperature = self._evo_device.setpointStatus["targetHeatTemperature"] - if evo_preset_mode == EVO_TEMPOVER: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) else: # EVO_PERMOVER until = None + temperature = self._evo_device.target_heat_temperature + assert temperature is not None # mypy check + until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( self._evo_device.set_temperature(temperature, until=until) @@ -306,14 +348,18 @@ class EvoController(EvoClimateEntity): _attr_icon = "mdi:thermostat" _attr_precision = PRECISION_TENTHS - def __init__(self, evo_broker, evo_device) -> None: + _evo_device: evo.ControlSystem # mypy hint + + def __init__(self, evo_broker: EvoBroker, evo_device: evo.ControlSystem) -> None: """Initialize a Honeywell TCC Controller/Location.""" + super().__init__(evo_broker, evo_device) + self._evo_id = evo_device.systemId self._attr_unique_id = evo_device.systemId self._attr_name = evo_device.location.name - modes = [m["systemMode"] for m in evo_broker.config["allowedSystemModes"]] + modes = [m[SZ_SYSTEM_MODE] for m in evo_broker.config[SZ_ALLOWED_SYSTEM_MODES]] self._attr_preset_modes = [ TCS_PRESET_TO_HA[m] for m in modes if m in list(TCS_PRESET_TO_HA) ] @@ -342,17 +388,17 @@ async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> Non await self._set_tcs_mode(mode, until=until) - async def _set_tcs_mode(self, mode: str, until: dt | None = None) -> None: + async def _set_tcs_mode(self, mode: str, until: datetime | None = None) -> None: """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_mode(mode, until=until) + self._evo_tcs.set_mode(mode, until=until) # type: ignore[arg-type] ) @property def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Controller.""" - tcs_mode = self._evo_tcs.systemModeStatus["mode"] + tcs_mode = self._evo_tcs.system_mode return HVACMode.OFF if tcs_mode == EVO_HEATOFF else HVACMode.HEAT @property @@ -362,16 +408,18 @@ def current_temperature(self) -> float | None: Controllers do not have a current temp, but one is expected by HA. """ temps = [ - z.temperatureStatus["temperature"] + z.temperature for z in self._evo_tcs.zones.values() - if z.temperatureStatus["isAvailable"] + if z.temperature is not None ] return round(sum(temps) / len(temps), 1) if temps else None @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) + if not self._evo_tcs.system_mode: + return None + return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Raise exception as Controllers don't have a target temperature.""" @@ -393,7 +441,7 @@ async def async_update(self) -> None: attrs = self._device_state_attrs for attr in STATE_ATTRS_TCS: - if attr == "activeFaults": + if attr == SZ_ACTIVE_FAULTS: attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr) else: attrs[attr] = getattr(self._evo_tcs, attr) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 58efb2c25b2719..062bba1cfdc84b 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.4.6"] + "requirements": ["evohome-async==0.4.15"] } diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index a16395ad6c0278..60dcf37ebb0ebb 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 aa38ee170a5db1..9e88c9bb0310cd 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 5d49e9b46ec566..77a7b1c2ced474 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -2,6 +2,17 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING, Any + +import evohomeasync2 as evo +from evohomeasync2.schema.const import ( + SZ_ACTIVE_FAULTS, + SZ_DHW_ID, + SZ_OFF, + SZ_ON, + SZ_STATE_STATUS, + SZ_TEMPERATURE_STATUS, +) from homeassistant.components.water_heater import ( WaterHeaterEntity, @@ -22,14 +33,18 @@ from . import EvoChild from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER +if TYPE_CHECKING: + from . import EvoBroker + + _LOGGER = logging.getLogger(__name__) STATE_AUTO = "auto" -HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: "On", STATE_OFF: "Off"} +HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: SZ_ON, STATE_OFF: SZ_OFF} EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} -STATE_ATTRS_DHW = ["dhwId", "activeFaults", "stateStatus", "temperatureStatus"] +STATE_ATTRS_DHW = [SZ_DHW_ID, SZ_ACTIVE_FAULTS, SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS] async def async_setup_platform( @@ -42,7 +57,9 @@ async def async_setup_platform( if discovery_info is None: return - broker = hass.data[DOMAIN]["broker"] + broker: EvoBroker = hass.data[DOMAIN]["broker"] + + assert broker.tcs.hotwater is not None # mypy check _LOGGER.debug( "Adding: DhwController (%s), id=%s", @@ -63,9 +80,13 @@ class EvoDHW(EvoChild, WaterHeaterEntity): _attr_operation_list = list(HA_STATE_TO_EVO) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, evo_broker, evo_device) -> None: + _evo_device: evo.HotWater # mypy hint + + def __init__(self, evo_broker: EvoBroker, evo_device: evo.HotWater) -> None: """Initialize an evohome DHW controller.""" + super().__init__(evo_broker, evo_device) + self._evo_id = evo_device.dhwId self._attr_unique_id = evo_device.dhwId @@ -77,17 +98,21 @@ def __init__(self, evo_broker, evo_device) -> None: ) @property - def current_operation(self) -> str: + def current_operation(self) -> str | None: """Return the current operating mode (Auto, On, or Off).""" - if self._evo_device.stateStatus["mode"] == EVO_FOLLOW: + if self._evo_device.mode == EVO_FOLLOW: return STATE_AUTO - return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] + if (device_state := self._evo_device.state) is None: + return None + return EVO_STATE_TO_HA[device_state] @property - def is_away_mode_on(self): + def is_away_mode_on(self) -> bool | None: """Return True if away mode is on.""" - is_off = EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] == STATE_OFF - is_permanent = self._evo_device.stateStatus["mode"] == EVO_PERMOVER + if self._evo_device.state is None: + return None + is_off = EVO_STATE_TO_HA[self._evo_device.state] == STATE_OFF + is_permanent = self._evo_device.mode == EVO_PERMOVER return is_off and is_permanent async def async_set_operation_mode(self, operation_mode: str) -> None: @@ -119,11 +144,11 @@ async def async_turn_away_mode_off(self) -> None: """Turn away mode off.""" await self._evo_broker.call_client_api(self._evo_device.reset_mode()) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" await self._evo_broker.call_client_api(self._evo_device.set_on()) - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" await self._evo_broker.call_client_api(self._evo_device.set_off()) diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index 4dd16b234803b1..1cdda152685cc3 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -33,14 +33,14 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class EzvizAlarmControlPanelEntityDescriptionMixin: """Mixin values for EZVIZ Alarm control panel entities.""" ezviz_alarm_states: list -@dataclass +@dataclass(frozen=True) class EzvizAlarmControlPanelEntityDescription( AlarmControlPanelEntityDescription, EzvizAlarmControlPanelEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index 2199f82a476815..abc44419075577 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class EzvizButtonEntityDescriptionMixin: """Mixin values for EZVIZ button entities.""" @@ -30,7 +30,7 @@ class EzvizButtonEntityDescriptionMixin: supported_ext: str -@dataclass +@dataclass(frozen=True) class EzvizButtonEntityDescription( ButtonEntityDescription, EzvizButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index ea7a4812b32baa..c922173aa877b3 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class EzvizNumberEntityDescriptionMixin: """Mixin values for EZVIZ Number entities.""" @@ -38,7 +38,7 @@ class EzvizNumberEntityDescriptionMixin: supported_ext_value: list -@dataclass +@dataclass(frozen=True) class EzvizNumberEntityDescription( NumberEntityDescription, EzvizNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index 369a429dbe6fb6..8110cf61a5cd4b 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -20,14 +20,14 @@ PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class EzvizSelectEntityDescriptionMixin: """Mixin values for EZVIZ Select entities.""" supported_switch: int -@dataclass +@dataclass(frozen=True) class EzvizSelectEntityDescription( SelectEntityDescription, EzvizSelectEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 4089b0ae393cf0..f6d19afae0c662 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -22,14 +22,14 @@ from .entity import EzvizEntity -@dataclass +@dataclass(frozen=True) class EzvizSwitchEntityDescriptionMixin: """Mixin values for EZVIZ Switch entities.""" supported_ext: str | None -@dataclass +@dataclass(frozen=True) class EzvizSwitchEntityDescription( SwitchEntityDescription, EzvizSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 5cbb206f223109..20bebcf08c89ed 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -1,44 +1,88 @@ """Platform for FAA Delays sensor component.""" from __future__ import annotations +from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import Any +from faadelays import Airport + from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) 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 . import FAADataUpdateCoordinator from .const import DOMAIN -FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): + """Mixin for required keys.""" + + is_on_fn: Callable[[Airport], bool | None] + extra_state_attributes_fn: Callable[[Airport], Mapping[str, Any]] + + +FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( + FaaDelaysBinarySensorEntityDescription( key="GROUND_DELAY", - name="Ground Delay", + translation_key="ground_delay", icon="mdi:airport", + is_on_fn=lambda airport: airport.ground_delay.status, + extra_state_attributes_fn=lambda airport: { + "average": airport.ground_delay.average, + "reason": airport.ground_delay.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="GROUND_STOP", - name="Ground Stop", + translation_key="ground_stop", icon="mdi:airport", + is_on_fn=lambda airport: airport.ground_stop.status, + extra_state_attributes_fn=lambda airport: { + "endtime": airport.ground_stop.endtime, + "reason": airport.ground_stop.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="DEPART_DELAY", - name="Departure Delay", + translation_key="depart_delay", icon="mdi:airplane-takeoff", + is_on_fn=lambda airport: airport.depart_delay.status, + extra_state_attributes_fn=lambda airport: { + "minimum": airport.depart_delay.minimum, + "maximum": airport.depart_delay.maximum, + "trend": airport.depart_delay.trend, + "reason": airport.depart_delay.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="ARRIVE_DELAY", - name="Arrival Delay", + translation_key="arrive_delay", icon="mdi:airplane-landing", + is_on_fn=lambda airport: airport.arrive_delay.status, + extra_state_attributes_fn=lambda airport: { + "minimum": airport.arrive_delay.minimum, + "maximum": airport.arrive_delay.maximum, + "trend": airport.arrive_delay.trend, + "reason": airport.arrive_delay.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="CLOSURE", - name="Closure", + translation_key="closure", icon="mdi:airplane:off", + is_on_fn=lambda airport: airport.closure.status, + extra_state_attributes_fn=lambda airport: { + "begin": airport.closure.start, + "end": airport.closure.end, + }, ), ) @@ -57,60 +101,38 @@ async def async_setup_entry( async_add_entities(entities) -class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): +class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorEntity): """Define a binary sensor for FAA Delays.""" + _attr_has_entity_name = True + + entity_description: FaaDelaysBinarySensorEntityDescription + def __init__( - self, coordinator, entry_id, description: BinarySensorEntityDescription + self, + coordinator: FAADataUpdateCoordinator, + entry_id: str, + description: FaaDelaysBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - - self.coordinator = coordinator - self._entry_id = entry_id - self._attrs: dict[str, Any] = {} _id = coordinator.data.code self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, _id)}, + name=_id, + manufacturer="Federal Aviation Administration", + entry_type=DeviceEntryType.SERVICE, + ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return the status of the sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "GROUND_DELAY": - return self.coordinator.data.ground_delay.status - if sensor_type == "GROUND_STOP": - return self.coordinator.data.ground_stop.status - if sensor_type == "DEPART_DELAY": - return self.coordinator.data.depart_delay.status - if sensor_type == "ARRIVE_DELAY": - return self.coordinator.data.arrive_delay.status - if sensor_type == "CLOSURE": - return self.coordinator.data.closure.status - return None + return self.entity_description.is_on_fn(self.coordinator.data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return attributes for sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "GROUND_DELAY": - self._attrs["average"] = self.coordinator.data.ground_delay.average - self._attrs["reason"] = self.coordinator.data.ground_delay.reason - elif sensor_type == "GROUND_STOP": - self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime - self._attrs["reason"] = self.coordinator.data.ground_stop.reason - elif sensor_type == "DEPART_DELAY": - self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum - self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum - self._attrs["trend"] = self.coordinator.data.depart_delay.trend - self._attrs["reason"] = self.coordinator.data.depart_delay.reason - elif sensor_type == "ARRIVE_DELAY": - self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum - self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum - self._attrs["trend"] = self.coordinator.data.arrive_delay.trend - self._attrs["reason"] = self.coordinator.data.arrive_delay.reason - elif sensor_type == "CLOSURE": - self._attrs["begin"] = self.coordinator.data.closure.start - self._attrs["end"] = self.coordinator.data.closure.end - return self._attrs + return self.entity_description.extra_state_attributes_fn(self.coordinator.data) diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py index b2f7f69dd49f32..2f91ce9f797c77 100644 --- a/homeassistant/components/faa_delays/config_flow.py +++ b/homeassistant/components/faa_delays/config_flow.py @@ -1,5 +1,6 @@ """Config flow for FAA Delays integration.""" import logging +from typing import Any from aiohttp import ClientConnectionError import faadelays @@ -7,6 +8,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_ID +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -21,7 +23,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: diff --git a/homeassistant/components/faa_delays/coordinator.py b/homeassistant/components/faa_delays/coordinator.py index f2aefdada66c1c..2f110cf7730fc1 100644 --- a/homeassistant/components/faa_delays/coordinator.py +++ b/homeassistant/components/faa_delays/coordinator.py @@ -6,6 +6,7 @@ from aiohttp import ClientConnectionError from faadelays import Airport +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,19 +15,18 @@ _LOGGER = logging.getLogger(__name__) -class FAADataUpdateCoordinator(DataUpdateCoordinator): +class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]): """Class to manage fetching FAA API data from a single endpoint.""" - def __init__(self, hass, code): + def __init__(self, hass: HomeAssistant, code: str) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) ) self.session = aiohttp_client.async_get_clientsession(hass) self.data = Airport(code, self.session) - self.code = code - async def _async_update_data(self): + async def _async_update_data(self) -> Airport: try: async with asyncio.timeout(10): await self.data.update() diff --git a/homeassistant/components/faa_delays/strings.json b/homeassistant/components/faa_delays/strings.json index 92a9dafb4da987..145c9e3ab34985 100644 --- a/homeassistant/components/faa_delays/strings.json +++ b/homeassistant/components/faa_delays/strings.json @@ -17,5 +17,76 @@ "abort": { "already_configured": "This airport is already configured." } + }, + "entity": { + "binary_sensor": { + "ground_delay": { + "name": "Ground delay", + "state_attributes": { + "average": { + "name": "Average" + }, + "reason": { + "name": "Reason" + } + } + }, + "ground_stop": { + "name": "Ground stop", + "state_attributes": { + "endtime": { + "name": "End time" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "depart_delay": { + "name": "Departure delay", + "state_attributes": { + "minimum": { + "name": "Minimum" + }, + "maximum": { + "name": "Maximum" + }, + "trend": { + "name": "Trend" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "arrive_delay": { + "name": "Arrival delay", + "state_attributes": { + "minimum": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::minimum::name%]" + }, + "maximum": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::maximum::name%]" + }, + "trend": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::trend::name%]" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "closure": { + "name": "Closure", + "state_attributes": { + "begin": { + "name": "Begin" + }, + "end": { + "name": "End" + } + } + } + } } } diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index a149909e029f2d..dedaedfe60002f 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -1,13 +1,12 @@ """Provides functionality to interact with fans.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft import logging import math -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -18,12 +17,18 @@ 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, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -33,6 +38,12 @@ ranged_value_to_percentage, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) DOMAIN = "fan" @@ -52,10 +63,22 @@ class FanEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the FanEntityFeature enum instead. -SUPPORT_SET_SPEED = 1 -SUPPORT_OSCILLATE = 2 -SUPPORT_DIRECTION = 4 -SUPPORT_PRESET_MODE = 8 +_DEPRECATED_SUPPORT_SET_SPEED = DeprecatedConstantEnum( + FanEntityFeature.SET_SPEED, "2025.1" +) +_DEPRECATED_SUPPORT_OSCILLATE = DeprecatedConstantEnum( + FanEntityFeature.OSCILLATE, "2025.1" +) +_DEPRECATED_SUPPORT_DIRECTION = DeprecatedConstantEnum( + FanEntityFeature.DIRECTION, "2025.1" +) +_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum( + FanEntityFeature.PRESET_MODE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) SERVICE_INCREASE_SPEED = "increase_speed" SERVICE_DECREASE_SPEED = "decrease_speed" @@ -77,8 +100,19 @@ class FanEntityFeature(IntFlag): # 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 +141,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 +190,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], ) @@ -175,12 +209,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class FanEntityDescription(ToggleEntityDescription): +class FanEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes fan entities.""" -class FanEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "percentage", + "speed_count", + "current_direction", + "oscillating", + "supported_features", + "preset_mode", + "preset_modes", +} + + +class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for fan entities.""" _entity_component_unrecorded_attributes = frozenset({ATTR_PRESET_MODES}) @@ -237,17 +281,30 @@ def set_preset_mode(self, preset_mode: str) -> None: """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 +324,18 @@ def turn_on( """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, @@ -298,14 +367,14 @@ def is_on(self) -> bool | None: self.percentage is not None and self.percentage > 0 ) or self.preset_mode is not None - @property + @cached_property def percentage(self) -> int | None: """Return the current speed as a percentage.""" if hasattr(self, "_attr_percentage"): return self._attr_percentage return 0 - @property + @cached_property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" if hasattr(self, "_attr_speed_count"): @@ -317,12 +386,12 @@ def percentage_step(self) -> float: """Return the step size for percentage.""" return 100 / self.speed_count - @property + @cached_property def current_direction(self) -> str | None: """Return the current direction of the fan.""" return self._attr_current_direction - @property + @cached_property def oscillating(self) -> bool | None: """Return whether or not the fan is currently oscillating.""" return self._attr_oscillating @@ -331,10 +400,11 @@ def oscillating(self) -> bool | None: def capability_attributes(self) -> dict[str, list[str] | None]: """Return capability attributes.""" attrs = {} + supported_features = self.supported_features_compat if ( - self.supported_features & FanEntityFeature.SET_SPEED - or self.supported_features & FanEntityFeature.PRESET_MODE + FanEntityFeature.SET_SPEED in supported_features + or FanEntityFeature.PRESET_MODE in supported_features ): attrs[ATTR_PRESET_MODES] = self.preset_modes @@ -345,32 +415,44 @@ def capability_attributes(self) -> dict[str, list[str] | None]: def state_attributes(self) -> dict[str, float | str | None]: """Return optional state attributes.""" data: dict[str, float | str | None] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat - if supported_features & FanEntityFeature.DIRECTION: + if FanEntityFeature.DIRECTION in supported_features: data[ATTR_DIRECTION] = self.current_direction - if supported_features & FanEntityFeature.OSCILLATE: + if FanEntityFeature.OSCILLATE in supported_features: data[ATTR_OSCILLATING] = self.oscillating - if supported_features & FanEntityFeature.SET_SPEED: + has_set_speed = FanEntityFeature.SET_SPEED in supported_features + + if has_set_speed: data[ATTR_PERCENTAGE] = self.percentage data[ATTR_PERCENTAGE_STEP] = self.percentage_step - if ( - supported_features & FanEntityFeature.PRESET_MODE - or supported_features & FanEntityFeature.SET_SPEED - ): + if has_set_speed or FanEntityFeature.PRESET_MODE in supported_features: data[ATTR_PRESET_MODE] = self.preset_mode return data - @property + @cached_property def supported_features(self) -> FanEntityFeature: """Flag supported features.""" return self._attr_supported_features @property + def supported_features_compat(self) -> FanEntityFeature: + """Return the supported features as FanEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = FanEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + + @cached_property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., auto, smart, interval, favorite. @@ -380,7 +462,7 @@ def preset_mode(self) -> str | None: return self._attr_preset_mode return None - @property + @cached_property def preset_modes(self) -> list[str] | None: """Return a list of available preset modes. diff --git a/homeassistant/components/fan/significant_change.py b/homeassistant/components/fan/significant_change.py new file mode 100644 index 00000000000000..b8038b93f795b9 --- /dev/null +++ b/homeassistant/components/fan/significant_change.py @@ -0,0 +1,61 @@ +"""Helper to test significant Fan state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name != ATTR_PERCENTAGE: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index 674dcc2b92ef90..aab714d3e07235 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 50e0cb04869581..165d81edd0bf22 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -1,30 +1,22 @@ """Support for testing internet speed via Fast.com.""" from __future__ import annotations -from datetime import datetime, timedelta import logging -from typing import Any -from fastdotcom import fast_com import voluptuous as vol -from homeassistant.const import CONF_SCAN_INTERVAL, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall +from homeassistant.helpers import issue_registry as ir 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, DEFAULT_INTERVAL, DOMAIN, PLATFORMS +from .coordinator import FastdotcomDataUpdateCoordindator _LOGGER = logging.getLogger(__name__) -CONF_MANUAL = "manual" - -DEFAULT_INTERVAL = timedelta(hours=1) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -41,37 +33,60 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Fast.com component.""" - conf = config[DOMAIN] - data = hass.data[DOMAIN] = SpeedtestData(hass) + """Set up the Fast.com component. (deprecated).""" + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + return True - if not conf[CONF_MANUAL]: - async_track_time_interval(hass, data.update, conf[CONF_SCAN_INTERVAL]) - def update(service_call: ServiceCall | None = None) -> None: - """Service call to manually update the data.""" - data.update() +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Fast.com from a config entry.""" + coordinator = FastdotcomDataUpdateCoordindator(hass) + + async def _request_refresh(event: Event) -> None: + """Request a refresh.""" + await coordinator.async_request_refresh() + + async def _request_refresh_service(call: ServiceCall) -> None: + """Request a refresh via the service.""" + ir.async_create_issue( + hass, + DOMAIN, + "service_deprecation", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation", + ) + await coordinator.async_request_refresh() - hass.services.async_register(DOMAIN, "speedtest", update) + if hass.state == CoreState.running: + await coordinator.async_config_entry_first_refresh() + else: + # Don't start the speedtest when HA is starting up + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _request_refresh) - hass.async_create_task( - async_load_platform(hass, Platform.SENSOR, DOMAIN, {}, config) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.services.async_register(DOMAIN, "speedtest", _request_refresh_service) + + await hass.config_entries.async_forward_entry_setups( + entry, + PLATFORMS, ) return True -class SpeedtestData: - """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: - """Get the latest data from fast.com.""" - - _LOGGER.debug("Executing fast.com speedtest") - self.data = {"download": fast_com()} - dispatcher_send(self._hass, DATA_UPDATED) +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Fast.com config entry.""" + hass.services.async_remove(DOMAIN, "speedtest") + 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/fastdotcom/config_flow.py b/homeassistant/components/fastdotcom/config_flow.py new file mode 100644 index 00000000000000..5ca35fd6802bcd --- /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 00000000000000..753825c4361a35 --- /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/coordinator.py b/homeassistant/components/fastdotcom/coordinator.py new file mode 100644 index 00000000000000..692a85d2edaf35 --- /dev/null +++ b/homeassistant/components/fastdotcom/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for the Fast.com integration.""" +from __future__ import annotations + +from datetime import timedelta + +from fastdotcom import fast_com + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_INTERVAL, DOMAIN, LOGGER + + +class FastdotcomDataUpdateCoordindator(DataUpdateCoordinator[float]): + """Class to manage fetching Fast.com data API.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the coordinator for Fast.com.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(hours=DEFAULT_INTERVAL), + ) + + async def _async_update_data(self) -> float: + """Run an executor job to retrieve Fast.com data.""" + try: + return await self.hass.async_add_executor_job(fast_com) + except Exception as exc: + raise UpdateFailed(f"Error communicating with Fast.com: {exc}") from exc diff --git a/homeassistant/components/fastdotcom/manifest.json b/homeassistant/components/fastdotcom/manifest.json index 73db5c0bf1197f..02fd3ade205503 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 b20b0213835080..2ca0b2d91686b3 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -1,68 +1,60 @@ """Support for Fast.com internet speed testing sensor.""" from __future__ import annotations -from typing import Any - from homeassistant.components.sensor import ( SensorDeviceClass, 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.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DATA_UPDATED, DOMAIN as FASTDOTCOM_DOMAIN +from .const import DOMAIN +from .coordinator import FastdotcomDataUpdateCoordindator -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])]) + coordinator: FastdotcomDataUpdateCoordindator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SpeedtestSensor(entry.entry_id, coordinator)]) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class SpeedtestSensor(RestoreEntity, SensorEntity): - """Implementation of a FAst.com sensor.""" +class SpeedtestSensor( + CoordinatorEntity[FastdotcomDataUpdateCoordindator], SensorEntity +): + """Implementation of a Fast.com sensor.""" - _attr_name = "Fast.com Download" + _attr_translation_key = "download" _attr_device_class = SensorDeviceClass.DATA_RATE _attr_native_unit_of_measurement = UnitOfDataRate.MEGABITS_PER_SECOND _attr_state_class = SensorStateClass.MEASUREMENT _attr_icon = "mdi:speedometer" _attr_should_poll = False + _attr_has_entity_name = True - def __init__(self, speedtest_data: dict[str, Any]) -> None: + def __init__( + self, entry_id: str, coordinator: FastdotcomDataUpdateCoordindator + ) -> None: """Initialize the sensor.""" - self._speedtest_data = speedtest_data - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) + super().__init__(coordinator) + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + configuration_url="https://www.fast.com", ) - if not (state := await self.async_get_last_state()): - return - self._attr_native_value = state.state - - def update(self) -> None: - """Get the latest data and update the states.""" - if (data := self._speedtest_data.data) is None: # type: ignore[attr-defined] - return - self._attr_native_value = data["download"] - - @callback - def _schedule_immediate_update(self) -> None: - self.async_schedule_update_ha_state(True) + @property + def native_value( + self, + ) -> float: + """Return the state of the sensor.""" + return self.coordinator.data diff --git a/homeassistant/components/fastdotcom/strings.json b/homeassistant/components/fastdotcom/strings.json index 705eada93875e9..61a1f6867479e6 100644 --- a/homeassistant/components/fastdotcom/strings.json +++ b/homeassistant/components/fastdotcom/strings.json @@ -1,8 +1,38 @@ { + "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%]" + } + }, + "entity": { + "sensor": { + "download": { + "name": "Download" + } + } + }, "services": { "speedtest": { "name": "Speed test", "description": "Immediately executes a speed test with Fast.com." } + }, + "issues": { + "service_deprecation": { + "title": "Fast.com speedtest service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::fastdotcom::issues::service_deprecation::title%]", + "description": "Use `homeassistant.update_entity` instead to update the data.\n\nPlease replace this service and adjust your automations and scripts and select **submit** to fix this issue." + } + } + } + } } } diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index eef84996d56fc2..04511a1a98656d 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -197,35 +197,40 @@ def _update_and_fire_entry(self, entry: feedparser.FeedParserDict) -> None: ) entry.update({"feed_url": self._url}) self._hass.bus.fire(self._event_type, entry) + _LOGGER.debug("New event fired for entry %s", entry.get("link")) def _publish_new_entries(self) -> None: """Publish new entries to the event bus.""" assert self._feed is not None - new_entries = False + new_entry_count = 0 self._last_entry_timestamp = self._storage.get_timestamp(self._feed_id) if self._last_entry_timestamp: self._firstrun = False else: # Set last entry timestamp as epoch time if not available self._last_entry_timestamp = dt_util.utc_from_timestamp(0).timetuple() + # locally cache self._last_entry_timestamp so that entries published at identical times can be processed + last_entry_timestamp = self._last_entry_timestamp for entry in self._feed.entries: if ( self._firstrun or ( "published_parsed" in entry - and entry.published_parsed > self._last_entry_timestamp + and entry.published_parsed > last_entry_timestamp ) or ( "updated_parsed" in entry - and entry.updated_parsed > self._last_entry_timestamp + and entry.updated_parsed > last_entry_timestamp ) ): self._update_and_fire_entry(entry) - new_entries = True + new_entry_count += 1 else: - _LOGGER.debug("Entry %s already processed", entry) - if not new_entries: + _LOGGER.debug("Already processed entry %s", entry.get("link")) + if new_entry_count == 0: self._log_no_entries() + else: + _LOGGER.debug("%d entries published in feed %s", new_entry_count, self._url) self._firstrun = False diff --git a/homeassistant/components/feedreader/manifest.json b/homeassistant/components/feedreader/manifest.json index 922bf5551ee850..fe52dc4d4c2f2a 100644 --- a/homeassistant/components/feedreader/manifest.json +++ b/homeassistant/components/feedreader/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/feedreader", "iot_class": "cloud_polling", "loggers": ["feedparser", "sgmllib3k"], - "requirements": ["feedparser==6.0.10"] + "requirements": ["feedparser==6.0.11"] } diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 981b81fdd4339e..17de9a6636afc8 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -1,9 +1,7 @@ """Support for Fibaro lights.""" from __future__ import annotations -import asyncio from contextlib import suppress -from functools import partial from typing import Any from pyfibaro.fibaro_device import DeviceModel @@ -68,8 +66,6 @@ class FibaroLight(FibaroDevice, LightEntity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the light.""" - self._update_lock = asyncio.Lock() - supports_color = ( "color" in fibaro_device.properties or "colorComponents" in fibaro_device.properties @@ -106,13 +102,8 @@ def __init__(self, fibaro_device: DeviceModel) -> None: super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - async def async_turn_on(self, **kwargs: Any) -> None: + def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - async with self._update_lock: - await self.hass.async_add_executor_job(partial(self._turn_on, **kwargs)) - - def _turn_on(self, **kwargs): - """Really turn the light on.""" if ATTR_BRIGHTNESS in kwargs: self._attr_brightness = kwargs[ATTR_BRIGHTNESS] self.set_level(scaleto99(self._attr_brightness)) @@ -120,26 +111,23 @@ def _turn_on(self, **kwargs): if ATTR_RGB_COLOR in kwargs: # Update based on parameters - self._attr_rgb_color = kwargs[ATTR_RGB_COLOR] - self.call_set_color(*self._attr_rgb_color, 0) + rgb = kwargs[ATTR_RGB_COLOR] + self._attr_rgb_color = rgb + self.call_set_color(int(rgb[0]), int(rgb[1]), int(rgb[2]), 0) return if ATTR_RGBW_COLOR in kwargs: # Update based on parameters - self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR] - self.call_set_color(*self._attr_rgbw_color) + rgbw = kwargs[ATTR_RGBW_COLOR] + self._attr_rgbw_color = rgbw + self.call_set_color(int(rgbw[0]), int(rgbw[1]), int(rgbw[2]), int(rgbw[3])) return # The simplest case is left for last. No dimming, just switch on self.call_turn_on() - async def async_turn_off(self, **kwargs: Any) -> None: + def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - async with self._update_lock: - await self.hass.async_add_executor_job(partial(self._turn_off, **kwargs)) - - def _turn_off(self, **kwargs): - """Really turn the light off.""" self.call_turn_off() @property @@ -165,13 +153,8 @@ def is_on(self) -> bool | None: return False - async def async_update(self) -> None: + def update(self) -> None: """Update the state.""" - async with self._update_lock: - await self.hass.async_add_executor_job(self._update) - - def _update(self): - """Really update the state.""" super().update() # Brightness handling if brightness_supported(self.supported_color_modes): diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json index 821298434d9cd6..063e612d35d38c 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 3b9610545448fe..c969adfe6378cc 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -168,14 +168,13 @@ def is_balance_account(self, account: SEPAAccount) -> bool: 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 account_type := account_information.get("type"): + return 1 <= account_type <= 9 + + 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 @@ def is_holdings_account(self, account: SEPAAccount) -> bool: 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 account_type := account_information.get("type"): + return 30 <= account_type <= 39 + + 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 @@ def detect_accounts(self) -> tuple[list, list]: 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/firmata/board.py b/homeassistant/components/firmata/board.py index c309676c8d6dda..233388d5013b8e 100644 --- a/homeassistant/components/firmata/board.py +++ b/homeassistant/components/firmata/board.py @@ -65,12 +65,12 @@ async def async_setup(self, tries=0) -> bool: except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", self.name, err) return False - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", self.name, err ) return False - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", self.name, diff --git a/homeassistant/components/firmata/config_flow.py b/homeassistant/components/firmata/config_flow.py index 8aa4cfb836c44a..f5b7cb5af409a3 100644 --- a/homeassistant/components/firmata/config_flow.py +++ b/homeassistant/components/firmata/config_flow.py @@ -41,12 +41,12 @@ async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", name, err) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", name, err ) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", name, err ) diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index ceb619c438538b..49e51a0fd98632 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -69,7 +69,7 @@ async def async_get_user_profile(self) -> FitbitProfile: profile = response["user"] self._profile = FitbitProfile( encoded_id=profile["encodedId"], - full_name=profile["fullName"], + display_name=profile["displayName"], locale=profile.get("locale"), ) return self._profile diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index caf0384eca269a..bbd7af09183e98 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -60,12 +60,17 @@ async def _post(self, data: dict[str, Any]) -> dict[str, Any]: resp.raise_for_status() except aiohttp.ClientResponseError as err: if _LOGGER.isEnabledFor(logging.DEBUG): - error_body = await resp.text() if not session.closed else "" + try: + error_body = await resp.text() + except aiohttp.ClientError: + error_body = "" _LOGGER.debug( "Client response error status=%s, body=%s", err.status, error_body ) if err.status == HTTPStatus.UNAUTHORIZED: raise FitbitAuthException(f"Unauthorized error: {err}") from err + if err.status == HTTPStatus.BAD_REQUEST: + raise FitbitAuthException(f"Bad Request error: {err}") from err raise FitbitApiException(f"Server error response: {err}") from err except aiohttp.ClientError as err: raise FitbitApiException(f"Client connection error: {err}") from err diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index dd7e79e2c65a69..7ef6ecbfa28fa5 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -90,7 +90,7 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: await self.async_set_unique_id(profile.encoded_id) self._abort_if_unique_id_configured() - return self.async_create_entry(title=profile.full_name, data=data) + return self.async_create_entry(title=profile.display_name, data=data) async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Handle import from YAML.""" diff --git a/homeassistant/components/fitbit/model.py b/homeassistant/components/fitbit/model.py index 38b1d0bb7861ef..cd8ece163a4b05 100644 --- a/homeassistant/components/fitbit/model.py +++ b/homeassistant/components/fitbit/model.py @@ -14,8 +14,8 @@ class FitbitProfile: encoded_id: str """The ID representing the Fitbit user.""" - full_name: str - """The first name value specified in the user's account settings.""" + display_name: str + """The name shown when the user's friends look at their Fitbit profile.""" locale: str | None """The locale defined in the user's Fitbit account settings.""" diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 336a662003518a..eb7d3b02b4d1f6 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -135,7 +135,18 @@ def _water_unit(unit_system: FitbitUnitSystem) -> UnitOfVolume: return UnitOfVolume.MILLILITERS -@dataclass +def _int_value_or_none(field: str) -> Callable[[dict[str, Any]], int | None]: + """Value function that will parse the specified field if present.""" + + def convert(result: dict[str, Any]) -> int | None: + if (value := result["value"].get(field)) is not None: + return int(value) + return None + + return convert + + +@dataclass(frozen=True) class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" @@ -207,7 +218,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): name="Resting Heart Rate", native_unit_of_measurement="bpm", icon="mdi:heart-pulse", - value_fn=lambda result: int(result["value"]["restingHeartRate"]), + value_fn=_int_value_or_none("restingHeartRate"), scope=FitbitScope.HEART_RATE, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index d941121e4da373..e1ca1b01f7a681 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -24,8 +24,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "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]" + "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/fivem/binary_sensor.py b/homeassistant/components/fivem/binary_sensor.py index 153732d2ce500e..ee46067f443a74 100644 --- a/homeassistant/components/fivem/binary_sensor.py +++ b/homeassistant/components/fivem/binary_sensor.py @@ -14,7 +14,7 @@ from .entity import FiveMEntity, FiveMEntityDescription -@dataclass +@dataclass(frozen=True) class FiveMBinarySensorEntityDescription( BinarySensorEntityDescription, FiveMEntityDescription ): diff --git a/homeassistant/components/fivem/entity.py b/homeassistant/components/fivem/entity.py index c11378ff049324..69204b559aeeaf 100644 --- a/homeassistant/components/fivem/entity.py +++ b/homeassistant/components/fivem/entity.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FiveMEntityDescription(EntityDescription): """Describes FiveM entity.""" diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py index 1c4e4b77c45a97..967a1392fe545c 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -24,7 +24,7 @@ from .entity import FiveMEntity, FiveMEntityDescription -@dataclass +@dataclass(frozen=True) class FiveMSensorEntityDescription(SensorEntityDescription, FiveMEntityDescription): """Describes FiveM sensor entity.""" diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index 2ffb401f8c06dd..abdef61fb28e0c 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/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index 41cdc0dbffe4bc..03302d490a62b7 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -22,7 +22,7 @@ from .coordinator import FjaraskupanCoordinator -@dataclass +@dataclass(frozen=True) class EntityDescription(BinarySensorEntityDescription): """Entity description.""" diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index 142694a6bfb204..ee989bb2ee0b0b 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -131,11 +131,9 @@ async def async_turn_on( 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/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000000..c9a0b332d93641 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -0,0 +1,43 @@ +"""The Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Flexit Nordic (BACnet) from a config entry.""" + + device = FlexitBACnet(entry.data[CONF_IP_ADDRESS], entry.data[CONF_DEVICE_ID]) + + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise ConfigEntryNotReady( + f"Timeout while connecting to {entry.data['address']}" + ) from exc + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device + + 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/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py new file mode 100644 index 00000000000000..c15cb59a6f38e0 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -0,0 +1,148 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from typing import Any + +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, + FlexitBACnet, +) +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +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 .const import ( + DOMAIN, + PRESET_TO_VENTILATION_MODE_MAP, + VENTILATION_TO_PRESET_MODE_MAP, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up the Flexit Nordic unit.""" + device = hass.data[DOMAIN][config_entry.entry_id] + + async_add_devices([FlexitClimateEntity(device)]) + + +class FlexitClimateEntity(ClimateEntity): + """Flexit air handling unit.""" + + _attr_name = None + + _attr_has_entity_name = True + + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.FAN_ONLY, + ] + + _attr_preset_modes = [ + PRESET_AWAY, + PRESET_HOME, + PRESET_BOOST, + ] + + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + + _attr_target_temperature_step = PRECISION_HALVES + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, device: FlexitBACnet) -> None: + """Initialize the unit.""" + self._device = device + self._attr_unique_id = device.serial_number + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, device.serial_number), + }, + name=device.device_name, + manufacturer="Flexit", + model="Nordic", + serial_number=device.serial_number, + ) + + async def async_update(self) -> None: + """Refresh unit state.""" + await self._device.update() + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return self._device.room_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + return self._device.air_temp_setpoint_away + + return self._device.air_temp_setpoint_home + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + try: + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + await self._device.set_air_temp_setpoint_away(temperature) + else: + await self._device.set_air_temp_setpoint_home(temperature) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return VENTILATION_TO_PRESET_MODE_MAP[self._device.ventilation_mode] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode] + + try: + await self._device.set_ventilation_mode(ventilation_mode) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if self._device.ventilation_mode == VENTILATION_MODE_STOP: + return HVACMode.OFF + + return HVACMode.FAN_ONLY + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + try: + if hvac_mode == HVACMode.OFF: + await self._device.set_ventilation_mode(VENTILATION_MODE_STOP) + else: + await self._device.set_ventilation_mode(VENTILATION_MODE_HOME) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc diff --git a/homeassistant/components/flexit_bacnet/config_flow.py b/homeassistant/components/flexit_bacnet/config_flow.py new file mode 100644 index 00000000000000..2c87dfc5b97df2 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions +import logging +from typing import Any + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICE_ID = 2 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_DEVICE_ID, default=DEFAULT_DEVICE_ID): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Flexit Nordic (BACnet).""" + + 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: + device = FlexitBACnet( + user_input[CONF_IP_ADDRESS], user_input[CONF_DEVICE_ID] + ) + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=device.device_name, data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/flexit_bacnet/const.py b/homeassistant/components/flexit_bacnet/const.py new file mode 100644 index 00000000000000..269a88c4cec8cc --- /dev/null +++ b/homeassistant/components/flexit_bacnet/const.py @@ -0,0 +1,30 @@ +"""Constants for the Flexit Nordic (BACnet) integration.""" +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HIGH, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, +) + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + PRESET_NONE, +) + +DOMAIN = "flexit_bacnet" + +VENTILATION_TO_PRESET_MODE_MAP = { + VENTILATION_MODE_STOP: PRESET_NONE, + VENTILATION_MODE_AWAY: PRESET_AWAY, + VENTILATION_MODE_HOME: PRESET_HOME, + VENTILATION_MODE_HIGH: PRESET_BOOST, +} + +PRESET_TO_VENTILATION_MODE_MAP = { + PRESET_NONE: VENTILATION_MODE_STOP, + PRESET_AWAY: VENTILATION_MODE_AWAY, + PRESET_HOME: VENTILATION_MODE_HOME, + PRESET_BOOST: VENTILATION_MODE_HIGH, +} diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json new file mode 100644 index 00000000000000..d230e4ebb7a7e6 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "flexit_bacnet", + "name": "Flexit Nordic (BACnet)", + "codeowners": ["@lellky", "@piotrbulinski"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["flexit_bacnet==2.1.0"] +} diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json new file mode 100644 index 00000000000000..fd2725c6403fc0 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "device_id": "[%key:common::config_flow::data::device%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index 627f562be7e14c..3444911fbd456f 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/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index 2305cd9f23eb49..fd6fcc5f4b9436 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -39,14 +39,14 @@ ) -@dataclass +@dataclass(frozen=True) class FlumeBinarySensorRequiredKeysMixin: """Mixin for required keys.""" event_rule: str -@dataclass +@dataclass(frozen=True) class FlumeBinarySensorEntityDescription( BinarySensorEntityDescription, FlumeBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 11e045bec703d1..9094006c791e8e 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations import contextlib -from typing import Any, Final, cast +from typing import Any, cast from flux_led.const import ( ATTR_ID, @@ -17,7 +17,7 @@ from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr @@ -47,8 +47,6 @@ ) from .util import format_as_flux_mac, mac_matches_by_one -CONF_DEVICE: Final = "device" - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Magic Home Integration.""" diff --git a/homeassistant/components/flux_led/const.py b/homeassistant/components/flux_led/const.py index db545aa1e68248..8b42f5f2e0dddf 100644 --- a/homeassistant/components/flux_led/const.py +++ b/homeassistant/components/flux_led/const.py @@ -65,7 +65,6 @@ CONF_COLORS: Final = "colors" CONF_SPEED_PCT: Final = "speed_pct" CONF_TRANSITION: Final = "transition" -CONF_EFFECT: Final = "effect" EFFECT_SPEED_SUPPORT_MODES: Final = {ColorMode.RGB, ColorMode.RGBW, ColorMode.RGBWW} diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index d880d517f1ab6c..1232cb41031c94 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -22,6 +22,7 @@ LightEntity, LightEntityFeature, ) +from homeassistant.const import CONF_EFFECT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv @@ -37,7 +38,6 @@ CONF_CUSTOM_EFFECT_COLORS, CONF_CUSTOM_EFFECT_SPEED_PCT, CONF_CUSTOM_EFFECT_TRANSITION, - CONF_EFFECT, CONF_SPEED_PCT, CONF_TRANSITION, DEFAULT_EFFECT_SPEED, diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 7a2723ce591e0d..68a3fe818678b5 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -27,7 +27,7 @@ from .coordinator import ForecastSolarDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class ForecastSolarSensorEntityDescription(SensorEntityDescription): """Describes a Forecast.Solar Sensor.""" diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index ef88d0f671ac8d..057ef4dbe8cdb0 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -15,15 +15,28 @@ from .config_flow import DEFAULT_RTSP_PORT from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET +from .coordinator import FoscamCoordinator PLATFORMS = [Platform.CAMERA] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up foscam from a config entry.""" - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data + session = FoscamCamera( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + verbose=False, + ) + coordinator = FoscamCoordinator(hass, session) + + 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 diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 384aea4c5fa8e9..c07ddfd9bfbd5d 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -3,16 +3,16 @@ import asyncio -from libpyfoscam import FoscamCamera import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( CONF_RTSP_PORT, @@ -22,6 +22,7 @@ SERVICE_PTZ, SERVICE_PTZ_PRESET, ) +from .coordinator import FoscamCoordinator DIR_UP = "up" DIR_DOWN = "down" @@ -88,28 +89,27 @@ async def async_setup_entry( "async_perform_ptz_preset", ) - camera = FoscamCamera( - config_entry.data[CONF_HOST], - config_entry.data[CONF_PORT], - config_entry.data[CONF_USERNAME], - config_entry.data[CONF_PASSWORD], - verbose=False, - ) + coordinator: FoscamCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([HassFoscamCamera(camera, config_entry)]) + async_add_entities([HassFoscamCamera(coordinator, config_entry)]) -class HassFoscamCamera(Camera): +class HassFoscamCamera(CoordinatorEntity[FoscamCoordinator], Camera): """An implementation of a Foscam IP camera.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, camera: FoscamCamera, config_entry: ConfigEntry) -> None: + def __init__( + self, + coordinator: FoscamCoordinator, + config_entry: ConfigEntry, + ) -> None: """Initialize a Foscam camera.""" - super().__init__() + super().__init__(coordinator) + Camera.__init__(self) - self._foscam_session = camera + self._foscam_session = coordinator.session self._username = config_entry.data[CONF_USERNAME] self._password = config_entry.data[CONF_PASSWORD] self._stream = config_entry.data[CONF_STREAM] @@ -125,6 +125,9 @@ def __init__(self, camera: FoscamCamera, config_entry: ConfigEntry) -> None: async def async_added_to_hass(self) -> None: """Handle entity addition to hass.""" # Get motion detection status + + await super().async_added_to_hass() + ret, response = await self.hass.async_add_executor_job( self._foscam_session.get_motion_detect_config ) diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py new file mode 100644 index 00000000000000..063d5235c04c52 --- /dev/null +++ b/homeassistant/components/foscam/coordinator.py @@ -0,0 +1,47 @@ +"""The foscam coordinator object.""" + +import asyncio +from datetime import timedelta +from typing import Any + +from libpyfoscam import FoscamCamera + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER + + +class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Foscam coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + session: FoscamCamera, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.session = session + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from API endpoint.""" + + async with asyncio.timeout(30): + data = {} + ret, dev_info = await self.hass.async_add_executor_job( + self.session.get_dev_info + ) + if ret == 0: + data["dev_info"] = dev_info + + all_info = await self.hass.async_add_executor_job( + self.session.get_product_all_info + ) + data["product_info"] = all_info[1] + return data diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index fc7cbb72e3c2bc..da4e9f53af4e7c 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -1,7 +1,7 @@ { "domain": "foscam", "name": "Foscam", - "codeowners": ["@skgsergio"], + "codeowners": ["@skgsergio", "@krmarien"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/foscam", "iot_class": "local_polling", diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 35964ee4546ce9..de22006b274597 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 index 52b7109045cf78..be3d88cf5b4f11 100644 --- a/homeassistant/components/freebox/alarm_control_panel.py +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -1,5 +1,4 @@ """Support for Freebox alarms.""" -import logging from typing import Any from homeassistant.components.alarm_control_panel import ( @@ -9,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, @@ -25,16 +24,14 @@ "alarm1_arming": STATE_ALARM_ARMING, "alarm2_arming": STATE_ALARM_ARMING, "alarm1_armed": STATE_ALARM_ARMED_AWAY, - "alarm2_armed": STATE_ALARM_ARMED_NIGHT, + "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, } -_LOGGER = logging.getLogger(__name__) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -76,63 +73,33 @@ def __init__( self._command_state = self.get_command_id( node["type"]["endpoints"], "signal", "state" ) - self._set_features(self._router.home_devices[self._id]) + + 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.""" - if await self.set_home_endpoint_value(self._command_disarm): - self._set_state(STATE_ALARM_DISARMED) + 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.""" - if await self.set_home_endpoint_value(self._command_arm_away): - self._set_state(STATE_ALARM_ARMING) + 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.""" - if await self.set_home_endpoint_value(self._command_arm_home): - self._set_state(STATE_ALARM_ARMING) + 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.""" - if await self.set_home_endpoint_value(self._command_trigger): - self._set_state(STATE_ALARM_TRIGGERED) + await self.set_home_endpoint_value(self._command_trigger) - async def async_update_signal(self): - """Update signal.""" - state = await self.get_home_endpoint_value(self._command_state) + async def async_update(self) -> None: + """Update state.""" + state: str | None = await self.get_home_endpoint_value(self._command_state) if state: - self._set_state(state) - - def _set_features(self, node: dict[str, Any]) -> None: - """Add alarm features.""" - # Search if the arm home feature is present => has an "alarm2" endpoint - can_arm_home = False - for nodeid, local_node in self._router.home_devices.items(): - if nodeid == local_node["id"]: - alarm2 = next( - filter( - lambda x: (x["name"] == "alarm2" and x["ep_type"] == "signal"), - local_node["show_endpoints"], - ), - None, - ) - if alarm2: - can_arm_home = alarm2["value"] - break - - if can_arm_home: - self._attr_supported_features = ( - AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_HOME - ) - + self._attr_state = FREEBOX_TO_STATUS.get(state) else: - self._attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY - - def _set_state(self, state: str) -> None: - """Update state.""" - self._attr_state = FREEBOX_TO_STATUS.get(state) - if not self._attr_state: - self._attr_state = STATE_ALARM_DISARMED - self.async_write_ha_state() + self._attr_state = None diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py index e3a206b43a89ed..d1268fb91d2528 100644 --- a/homeassistant/components/freebox/button.py +++ b/homeassistant/components/freebox/button.py @@ -18,14 +18,14 @@ from .router import FreeboxRouter -@dataclass +@dataclass(frozen=True) class FreeboxButtonRequiredKeysMixin: """Mixin for required keys.""" async_press: Callable[[FreeboxRouter], Awaitable] -@dataclass +@dataclass(frozen=True) class FreeboxButtonEntityDescription( ButtonEntityDescription, FreeboxButtonRequiredKeysMixin ): diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index 2cc1a5fcfe33ce..022528e5ea7c22 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -131,13 +131,14 @@ def remove_signal_update(self, dispacher: Any): 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 6a73624a77674d..765761c43f27ce 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -4,9 +4,11 @@ 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 @@ _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 @@ def __init__( 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 @@ async def update_all(self, now: datetime | None = None) -> None: 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 5c4143b4562ac8..eaa56a38da1015 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/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 6d371a82c95aa3..00e9f406ed4cb7 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FritzBinarySensorEntityDescription( BinarySensorEntityDescription, FritzEntityDescription ): diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index a4504996820da1..5b4a3f5a20cd5c 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -23,14 +23,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FritzButtonDescriptionMixin: """Mixin to describe a Button entity.""" press_action: Callable -@dataclass +@dataclass(frozen=True) class FritzButtonDescription(ButtonEntityDescription, FritzButtonDescriptionMixin): """Class to describe a Button entity.""" diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 2abba137fbf359..bad73d913206bf 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1063,6 +1063,7 @@ class SwitchInfo(TypedDict): type: str callback_update: Callable callback_switch: Callable + init_state: bool class FritzBoxBaseEntity: @@ -1092,14 +1093,14 @@ def device_info(self) -> DeviceInfo: ) -@dataclass +@dataclass(frozen=True) class FritzRequireKeysMixin: """Fritz entity description mix in.""" value_fn: Callable[[FritzStatus, Any], Any] | None -@dataclass +@dataclass(frozen=True) class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): """Fritz entity base description.""" diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index d14c562bd7652b..aa1ede5a1853da 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -5,6 +5,8 @@ from io import BytesIO import logging +from requests.exceptions import RequestException + from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -78,7 +80,13 @@ async def async_added_to_hass(self) -> None: async def async_update(self) -> None: """Update the image entity data.""" - qr_bytes = await self._fetch_image() + try: + qr_bytes = await self._fetch_image() + except RequestException: + self._current_qr_bytes = None + self._attr_image_last_updated = None + self.async_write_ha_state() + return if self._current_qr_bytes != qr_bytes: dt_now = dt_util.utcnow() diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index d6b78c1cfc0906..53a299cd576745 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -142,7 +142,7 @@ def _retrieve_link_attenuation_received_state( return status.attenuation[1] / 10 # type: ignore[no-any-return] -@dataclass +@dataclass(frozen=True) class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): """Describes Fritz sensor entity.""" diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 7cbb10a236b21c..5eed2f59fc4d43 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/fritz/switch.py b/homeassistant/components/fritz/switch.py index 026c0f3d6fbfe8..c3da6b5af0b8b8 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -166,9 +166,7 @@ async def _async_wifi_entities_list( _LOGGER.debug("WiFi networks list: %s", networks) return [ - FritzBoxWifiSwitch( - avm_wrapper, device_friendly_name, index, data["switch_name"] - ) + FritzBoxWifiSwitch(avm_wrapper, device_friendly_name, index, data) for index, data in networks.items() ] @@ -310,18 +308,16 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self._async_handle_turn_on_off(turn_on=False) -class FritzBoxBaseSwitch(FritzBoxBaseEntity): +class FritzBoxBaseSwitch(FritzBoxBaseEntity, SwitchEntity): """Fritz switch base class.""" - _attr_is_on: bool | None = False - def __init__( self, avm_wrapper: AvmWrapper, device_friendly_name: str, switch_info: SwitchInfo, ) -> None: - """Init Fritzbox port switch.""" + """Init Fritzbox base switch.""" super().__init__(avm_wrapper, device_friendly_name) self._description = switch_info["description"] @@ -330,6 +326,7 @@ def __init__( self._type = switch_info["type"] self._update = switch_info["callback_update"] self._switch = switch_info["callback_switch"] + self._attr_is_on = switch_info["init_state"] self._name = f"{self._friendly_name} {self._description}" self._unique_id = f"{self._avm_wrapper.unique_id}-{slugify(self._description)}" @@ -381,7 +378,7 @@ async def _async_handle_turn_on_off(self, turn_on: bool) -> None: self._attr_is_on = turn_on -class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): +class FritzBoxPortSwitch(FritzBoxBaseSwitch): """Defines a FRITZ!Box Tools PortForward switch.""" def __init__( @@ -412,6 +409,7 @@ def __init__( type=SWITCH_TYPE_PORTFORWARD, callback_update=self._async_fetch_update, callback_switch=self._async_switch_on_off_executor, + init_state=port_mapping["NewEnabled"], ) super().__init__(avm_wrapper, device_friendly_name, switch_info) @@ -553,7 +551,7 @@ async def _async_handle_turn_on_off(self, turn_on: bool) -> bool: return True -class FritzBoxWifiSwitch(FritzBoxBaseSwitch, SwitchEntity): +class FritzBoxWifiSwitch(FritzBoxBaseSwitch): """Defines a FRITZ!Box Tools Wifi switch.""" def __init__( @@ -561,7 +559,7 @@ def __init__( avm_wrapper: AvmWrapper, device_friendly_name: str, network_num: int, - network_name: str, + network_data: dict, ) -> None: """Init Fritz Wifi switch.""" self._avm_wrapper = avm_wrapper @@ -571,12 +569,13 @@ def __init__( self._network_num = network_num switch_info = SwitchInfo( - description=f"Wi-Fi {network_name}", + description=f"Wi-Fi {network_data['switch_name']}", friendly_name=device_friendly_name, icon="mdi:wifi", type=SWITCH_TYPE_WIFINETWORK, callback_update=self._async_fetch_update, callback_switch=self._async_switch_on_off_executor, + init_state=network_data["enabled"], ) super().__init__(self._avm_wrapper, device_friendly_name, switch_info) diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 80cbe1f4c5c1d6..fafd9c37ab889b 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): """Describes Fritz update entity.""" diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 5d30362627e8de..c6676bb1bbfaa7 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -14,23 +14,22 @@ ) 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 -@dataclass +@dataclass(frozen=True) class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): """BinarySensor description mixin for Fritz!Smarthome entities.""" is_on: Callable[[FritzhomeDevice], bool | None] -@dataclass +@dataclass(frozen=True) class FritzBinarySensorEntityDescription( BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor ): @@ -68,18 +67,25 @@ 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] - - async_add_entities( - [ + coordinator = get_coordinator(hass, entry.entry_id) + + @callback + def _add_entities(devices: set[str] | None = None) -> None: + """Add devices.""" + if devices is None: + devices = coordinator.new_devices + if not devices: + return + async_add_entities( FritzboxBinarySensor(coordinator, ain, description) - for ain, device in coordinator.data.devices.items() + for ain in 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(set(coordinator.data.devices)) class FritzboxBinarySensor(FritzBoxDeviceEntity, BinarySensorEntity): diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py index cc5457fb8a2bb2..6695c56433104e 100644 --- a/homeassistant/components/fritzbox/button.py +++ b/homeassistant/components/fritzbox/button.py @@ -3,25 +3,33 @@ 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(templates: set[str] | None = None) -> None: + """Add templates.""" + if templates is None: + templates = coordinator.new_templates + if not templates: + return + async_add_entities(FritzBoxTemplate(coordinator, ain) for ain in templates) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities(set(coordinator.data.templates)) class FritzBoxTemplate(FritzBoxEntity, ButtonEntity): @@ -37,7 +45,7 @@ def device_info(self) -> DeviceInfo: """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 7c84678963772e..f648d4b3966f78 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -18,17 +18,16 @@ 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,24 @@ 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] - - async_add_entities( - [ + coordinator = get_coordinator(hass, entry.entry_id) + + @callback + def _add_entities(devices: set[str] | None = None) -> None: + """Add devices.""" + if devices is None: + devices = coordinator.new_devices + if not devices: + return + async_add_entities( FritzboxThermostat(coordinator, ain) - for ain, device in coordinator.data.devices.items() - if device.has_thermostat - ] - ) + for ain in devices + if coordinator.data.devices[ain].has_thermostat + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities(set(coordinator.data.devices)) class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): diff --git a/homeassistant/components/fritzbox/common.py b/homeassistant/components/fritzbox/common.py new file mode 100644 index 00000000000000..ab87a51f9ce2e3 --- /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 194825e602f523..f6d210e367af13 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -37,6 +37,8 @@ def __init__( 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 @@ def __init__( update_interval=timedelta(seconds=30), ) + self.data = FritzboxCoordinatorData({}, {}) + def _update_fritz_devices(self) -> FritzboxCoordinatorData: """Update all fritzbox device data.""" try: @@ -87,6 +91,9 @@ def _update_fritz_devices(self) -> FritzboxCoordinatorData: 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 df3b1562f9b554..4c2ba76c3776b2 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -10,26 +10,35 @@ 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] - - async_add_entities( - FritzboxCover(coordinator, ain) - for ain, device in coordinator.data.devices.items() - if device.has_blind - ) + coordinator = get_coordinator(hass, entry.entry_id) + + @callback + def _add_entities(devices: set[str] | None = None) -> None: + """Add devices.""" + if devices is None: + devices = coordinator.new_devices + if not devices: + return + async_add_entities( + FritzboxCover(coordinator, ain) + for ain in devices + if coordinator.data.devices[ain].has_blind + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities(set(coordinator.data.devices)) class FritzboxCover(FritzBoxDeviceEntity, CoverEntity): diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index f83dd4545924a9..cb0c8594695f77 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -13,17 +13,12 @@ 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,24 @@ 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] - - 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 + coordinator = get_coordinator(hass, entry.entry_id) + + @callback + def _add_entities(devices: set[str] | None = None) -> None: + """Add devices.""" + if devices is None: + devices = coordinator.new_devices + if not devices: + return + async_add_entities( + FritzboxLight(coordinator, ain) + for ain in devices + if coordinator.data.devices[ain].has_lightbulb ) - supported_colors = await hass.async_add_executor_job(device.get_colors) - - entities.append( - FritzboxLight( - coordinator, - ain, - supported_colors, - supported_color_temps, - ) - ) + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) - async_add_entities(entities) + _add_entities(set(coordinator.data.devices)) class FritzboxLight(FritzBoxDeviceEntity, LightEntity): @@ -66,27 +54,10 @@ def __init__( self, coordinator: FritzboxDataUpdateCoordinator, ain: str, - supported_colors: dict, - supported_color_temps: list[int], ) -> None: """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - - if supported_color_temps: - # only available for color bulbs - self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) - self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) - - # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. - # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup self._supported_hs: dict[int, list[int]] = {} - for values in supported_colors.values(): - hue = int(values[0][0]) - self._supported_hs[hue] = [ - int(values[0][1]), - int(values[1][1]), - int(values[2][1]), - ] @property def is_on(self) -> bool: @@ -182,3 +153,28 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" await self.hass.async_add_executor_job(self.data.set_state_off) await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """Get light attributes from device after entity is added to hass.""" + await super().async_added_to_hass() + supported_colors = await self.hass.async_add_executor_job( + self.coordinator.data.devices[self.ain].get_colors + ) + supported_color_temps = await self.hass.async_add_executor_job( + self.coordinator.data.devices[self.ain].get_color_temps + ) + + if supported_color_temps: + # only available for color bulbs + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) + + # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. + # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup + for values in supported_colors.values(): + hue = int(values[0][0]) + self._supported_hs[hue] = [ + int(values[0][1]), + int(values[1][1]), + int(values[2][1]), + ] diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index 3c3275e0ff00b8..74c5bd4292734b 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -18,7 +18,7 @@ class ClimateExtraAttributes(TypedDict, total=False): window_open: bool -@dataclass +@dataclass(frozen=True) class FritzEntityDescriptionMixinBase: """Bases description mixin for Fritz!Smarthome entities.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 013c1dfc7b5c6c..fd55369d91569e 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -25,24 +25,24 @@ 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 -@dataclass +@dataclass(frozen=True) class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): """Sensor description mixin for Fritz!Smarthome entities.""" native_value: Callable[[FritzhomeDevice], StateType | datetime] -@dataclass +@dataclass(frozen=True) class FritzSensorEntityDescription( SensorEntityDescription, FritzEntityDescriptionMixinSensor ): @@ -212,16 +212,25 @@ 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] - - async_add_entities( - [ + coordinator = get_coordinator(hass, entry.entry_id) + + @callback + def _add_entities(devices: set[str] | None = None) -> None: + """Add devices.""" + if devices is None: + devices = coordinator.new_devices + if not devices: + return + async_add_entities( FritzBoxSensor(coordinator, ain, description) - for ain, device in coordinator.data.devices.items() + for ain in 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(set(coordinator.data.devices)) class FritzBoxSensor(FritzBoxDeviceEntity, SensorEntity): diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index d5607aa3090078..f4d2fe3670ef55 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 5eee3019633ea0..4d93cddb6176a9 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -5,28 +5,35 @@ 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] - - async_add_entities( - [ + coordinator = get_coordinator(hass, entry.entry_id) + + @callback + def _add_entities(devices: set[str] | None = None) -> None: + """Add devices.""" + if devices is None: + devices = coordinator.new_devices + if not devices: + return + async_add_entities( FritzboxSwitch(coordinator, ain) - for ain, device in coordinator.data.devices.items() - if device.has_switch - ] - ) + for ain in devices + if coordinator.data.devices[ain].has_switch + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities(set(coordinator.data.devices)) class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index cc239895c38238..03ac98419c17dc 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -56,18 +56,16 @@ async def async_setup_entry( FRITZBOX_PHONEBOOK ] - phonebook_name: str = config_entry.title phonebook_id: int = config_entry.data[CONF_PHONEBOOK] prefixes: list[str] | None = config_entry.options.get(CONF_PREFIXES) serial_number: str = config_entry.data[SERIAL_NUMBER] host: str = config_entry.data[CONF_HOST] port: int = config_entry.data[CONF_PORT] - name = f"{fritzbox_phonebook.fph.modelname} Call Monitor {phonebook_name}" unique_id = f"{serial_number}-{phonebook_id}" sensor = FritzBoxCallSensor( - name=name, + phonebook_name=config_entry.title, unique_id=unique_id, fritzbox_phonebook=fritzbox_phonebook, prefixes=prefixes, @@ -82,13 +80,14 @@ class FritzBoxCallSensor(SensorEntity): """Implementation of a Fritz!Box call monitor.""" _attr_icon = ICON_PHONE + _attr_has_entity_name = True _attr_translation_key = DOMAIN _attr_device_class = SensorDeviceClass.ENUM _attr_options = list(CallState) def __init__( self, - name: str, + phonebook_name: str, unique_id: str, fritzbox_phonebook: FritzBoxPhonebook, prefixes: list[str] | None, @@ -103,7 +102,7 @@ def __init__( self._monitor: FritzBoxCallMonitor | None = None self._attributes: dict[str, str | list[str]] = {} - self._attr_name = name.title() + self._attr_translation_placeholders = {"phonebook_name": phonebook_name} self._attr_unique_id = unique_id self._attr_native_value = CallState.IDLE self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 89f049bfbe90d2..9bfb1a6a7a01ff 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": { @@ -41,6 +44,7 @@ "entity": { "sensor": { "fritzbox_callmonitor": { + "name": "Call monitor {phonebook_name}", "state": { "ringing": "Ringing", "dialing": "Dialing", diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index c05f18107a043f..d0e13aa79143e4 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -65,6 +65,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return True + + class FroniusSolarNet: """The FroniusSolarNet class routes received values to sensor entities.""" diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py index 4060731b21cf66..18f35de8336c4c 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 94fd5f256aad4d..fcf9ce0a389b54 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -49,8 +49,10 @@ def __init__(self, *args: Any, solar_net: FroniusSolarNet, **kwargs: Any) -> Non """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 @@ async def _async_update_data(self) -> dict[SolarNetId, Any]: 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 @@ def add_entities_for_seen_keys( """ @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 - ): + 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 if device_data[key]["value"] is None: + remaining_unregistered_descriptors.append(description) continue - new_entities.append(entity_constructor(self, key, solar_net_id)) - self.unregistered_keys[solar_net_id].remove(key) + 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 f11855ce7e2277..93c13c8e5794c7 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.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 @@ -94,7 +104,7 @@ def async_add_new_entities(coordinator: FroniusInverterUpdateCoordinator) -> Non ) -@dataclass +@dataclass(frozen=True) class FroniusSensorEntityDescription(SensorEntityDescription): """Describes Fronius sensor entity.""" @@ -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 @@ class FroniusSensorEntityDescription(SensorEntityDescription): 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 @@ class FroniusSensorEntityDescription(SensorEntityDescription): 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 @@ class FroniusSensorEntityDescription(SensorEntityDescription): ), 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 @@ def _device_data(self) -> dict[str, Any]: 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 @@ def _handle_coordinator_update(self) -> None: 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 @@ def __init__( 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 @@ def __init__( 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 4a0f96ed8e61c6..de06670464424e 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/manifest.json b/homeassistant/components/frontend/manifest.json index 469deab23e1d9c..ad24f6bb12d79a 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==20240104.0"] } diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 82f169dc6c9645..91646dcb74586d 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -1,7 +1,7 @@ """API for persistent storage for the frontend.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from functools import wraps from typing import Any @@ -50,12 +50,19 @@ async def async_user_store( return store, data[user_id] -def with_store(orig_func: Callable) -> Callable: +def with_store( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Store, dict[str, Any]], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate function to provide data.""" @wraps(orig_func) async def with_store_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" user_id = connection.user.id diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index 62f2623d05ea71..f1e0ad48d30a04 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -6,11 +6,11 @@ from afsapi import AFSAPI, ConnectionError as FSConnectionError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_PIN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN +from .const import CONF_WEBFSAPI_URL, DOMAIN PLATFORMS = [Platform.MEDIA_PLAYER] diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 2274b1cdb4499c..470be7d9b26044 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -16,11 +16,10 @@ from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import ( - CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py index 34201fe8f4a316..94f4e09a35a859 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -2,7 +2,6 @@ DOMAIN = "frontier_silicon" CONF_WEBFSAPI_URL = "webfsapi_url" -CONF_PIN = "pin" SSDP_ST = "urn:schemas-frontier-silicon-com:undok:fsapi:1" SSDP_ATTR_SPEAKER_NAME = "SPEAKER-NAME" diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a10c3f535a1053..03d9f28c01629b 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/fujitsu_anywair/__init__.py b/homeassistant/components/fujitsu_anywair/__init__.py new file mode 100644 index 00000000000000..5845e00f8b0612 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/__init__.py @@ -0,0 +1 @@ +"""Fujitsu anywAIR virtual integration for Home Assistant.""" diff --git a/homeassistant/components/fujitsu_anywair/manifest.json b/homeassistant/components/fujitsu_anywair/manifest.json new file mode 100644 index 00000000000000..463f0724919b7a --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "fujitsu_anywair", + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" +} diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py index 9f4d60e9574803..0a6233937ae539 100644 --- a/homeassistant/components/fully_kiosk/button.py +++ b/homeassistant/components/fully_kiosk/button.py @@ -22,14 +22,14 @@ from .entity import FullyKioskEntity -@dataclass +@dataclass(frozen=True) class FullyButtonEntityDescriptionMixin: """Mixin to describe a Fully Kiosk Browser button entity.""" press_action: Callable[[FullyKiosk], Any] -@dataclass +@dataclass(frozen=True) class FullyButtonEntityDescription( ButtonEntityDescription, FullyButtonEntityDescriptionMixin ): @@ -54,16 +54,19 @@ class FullyButtonEntityDescription( FullyButtonEntityDescription( key="toForeground", translation_key="to_foreground", + entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.toForeground(), ), FullyButtonEntityDescription( key="toBackground", translation_key="to_background", + entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.toBackground(), ), FullyButtonEntityDescription( key="loadStartUrl", translation_key="load_start_url", + entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.loadStartUrl(), ), ) diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 7d744214d9337a..4f9dadd6901b25 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -12,7 +12,13 @@ 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 @@ def __init__(self) -> None: 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 @@ async def _create_entry( ) 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 @@ async def _create_entry( 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 @@ async def async_step_user( ) -> 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 @@ async def async_step_user( { 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 @@ async def async_step_discovery_confirm( 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 0cfc15268b4048..203251351ae5d5 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -6,7 +6,7 @@ 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 fcb6f35eb11acf..b053508ae414ba 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -1,7 +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 @@ -30,13 +36,20 @@ 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"] @@ -45,3 +58,30 @@ def __init__(self, coordinator: FullyKioskDataUpdateCoordinator) -> None: (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) + if "event" in payload and payload["event"] == event: + 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 dcd36671fce83f..b5dadf14184d40 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/number.py b/homeassistant/components/fully_kiosk/number.py index 298a58e2a112ad..4203a64074d33b 100644 --- a/homeassistant/components/fully_kiosk/number.py +++ b/homeassistant/components/fully_kiosk/number.py @@ -46,6 +46,7 @@ native_max_value=255, native_step=1, native_min_value=0, + entity_category=EntityCategory.CONFIG, ), ) diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index dd775e7d55a8f1..8e9029fda73b09 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -40,7 +40,7 @@ def truncate_url(value: StateType) -> tuple[StateType, dict[str, Any]]: return (url, extra_state_attributes) -@dataclass +@dataclass(frozen=True) class FullySensorEntityDescription(SensorEntityDescription): """Fully Kiosk Browser sensor description.""" diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index d61e8a7b7a89f1..c1a1ef1fcf05f8 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 500e154abd8339..d5480b784c48f2 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -10,7 +10,7 @@ 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 @@ -18,16 +18,18 @@ from .entity import FullyKioskEntity -@dataclass +@dataclass(frozen=True) class FullySwitchEntityDescriptionMixin: """Fully Kiosk Browser switch entity description mixin.""" 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 +@dataclass(frozen=True) class FullySwitchEntityDescription( SwitchEntityDescription, FullySwitchEntityDescriptionMixin ): @@ -41,6 +43,8 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ def __init__( super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.data['deviceID']}-{description.key}" - - @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) + self._turned_on_subscription: CALLBACK_TYPE | None = None + self._turned_off_subscription: CALLBACK_TYPE | None = None + + 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 @@ async def async_turn_off(self, **kwargs: Any) -> None: """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 3f4ffc7fae1482..3ce96152337cd6 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/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index b66cb8cd00daa3..bf905bc551d61e 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -20,7 +20,7 @@ from .coordinator import Coordinator, GardenaBluetoothDescriptorEntity -@dataclass +@dataclass(frozen=True) class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index 1ed738a9690a90..cbdbda5f367c62 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -16,7 +16,7 @@ from .coordinator import Coordinator, GardenaBluetoothDescriptorEntity -@dataclass +@dataclass(frozen=True) class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index bcbb25d55a2640..6598aeaafd8931 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena-bluetooth==1.4.0"] + "requirements": ["gardena-bluetooth==1.4.1"] } diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index f0ba5dbd2fe7c7..ef19a921041abe 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -29,7 +29,7 @@ ) -@dataclass +@dataclass(frozen=True) class GardenaBluetoothNumberEntityDescription(NumberEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 495a1fcb1eb968..ca2b1acdd8c405 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -27,7 +27,7 @@ ) -@dataclass +@dataclass(frozen=True) class GardenaBluetoothSensorEntityDescription(SensorEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 5d5589c54d6723..8a0a0113ceda0f 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -44,12 +45,14 @@ class GdacsSensor(SensorEntity): _attr_should_poll = False _attr_icon = DEFAULT_ICON _attr_native_unit_of_measurement = DEFAULT_UNIT_OF_MEASUREMENT + _attr_has_entity_name = True + _attr_name = None def __init__(self, config_entry: ConfigEntry, manager) -> None: """Initialize entity.""" + assert config_entry.unique_id self._config_entry_id = config_entry.entry_id self._attr_unique_id = config_entry.unique_id - self._attr_name = f"GDACS ({config_entry.title})" self._manager = manager self._status = None self._last_update = None @@ -60,6 +63,11 @@ def __init__(self, config_entry: ConfigEntry, manager) -> None: self._updated = None self._removed = None self._remove_signal_status: Callable[[], None] | None = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.unique_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="GDACS", + ) async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 621566a70f597c..f4c02a2ab9f729 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,7 +1,9 @@ """Support for IP Cameras.""" from __future__ import annotations +import asyncio from collections.abc import Mapping +from datetime import datetime, timedelta import logging from typing import Any @@ -33,6 +35,7 @@ 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 @@ -128,6 +131,8 @@ class GenericCamera(Camera): """A generic implementation of an IP camera.""" _last_image: bytes | None + _last_update: datetime + _update_lock: asyncio.Lock def __init__( self, @@ -171,6 +176,13 @@ def __init__( self._last_url = None self._last_image = None + self._last_update = datetime.min + self._update_lock = asyncio.Lock() + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer="Generic", + ) @property def use_stream_for_stills(self) -> bool: @@ -192,22 +204,39 @@ async def async_camera_image( if url == self._last_url and self._limit_refetch: return self._last_image - try: - async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) - response = await async_client.get( - url, auth=self._auth, follow_redirects=True, timeout=GET_IMAGE_TIMEOUT - ) - response.raise_for_status() - self._last_image = response.content - except httpx.TimeoutException: - _LOGGER.error("Timeout getting camera image from %s", self._name) - return self._last_image - except (httpx.RequestError, httpx.HTTPStatusError) as err: - _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) - return self._last_image + async with self._update_lock: + if ( + self._last_image is not None + and url == self._last_url + and self._last_update + timedelta(0, self._attr_frame_interval) + > datetime.now() + ): + return self._last_image + + try: + update_time = datetime.now() + async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) + response = await async_client.get( + url, + auth=self._auth, + follow_redirects=True, + timeout=GET_IMAGE_TIMEOUT, + ) + response.raise_for_status() + self._last_image = response.content + self._last_update = update_time - self._last_url = url - return self._last_image + except httpx.TimeoutException: + _LOGGER.error("Timeout getting camera image from %s", self._name) + return self._last_image + except (httpx.RequestError, httpx.HTTPStatusError) as err: + _LOGGER.error( + "Error getting new camera image from %s: %s", self._name, err + ) + return self._last_image + + self._last_url = url + return self._last_image @property def name(self): diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 79ba418d509fb8..7b9bf8f6112f1f 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -68,9 +68,12 @@ def device_class(self): 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_location/__init__.py b/homeassistant/components/geo_location/__init__.py index af64443ca28321..c5e91d32b203d0 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE @@ -16,6 +16,12 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) ATTR_DISTANCE = "distance" @@ -51,7 +57,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class GeolocationEvent(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "source", + "distance", + "latitude", + "longitude", +} + + +class GeolocationEvent(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for an external event with an associated geolocation.""" # Entity Properties @@ -68,22 +82,22 @@ def state(self) -> float | None: return round(self.distance, 1) return None - @property + @cached_property def source(self) -> str: """Return source value of this external event.""" return self._attr_source - @property + @cached_property def distance(self) -> float | None: """Return distance value of this external event.""" return self._attr_distance - @property + @cached_property def latitude(self) -> float | None: """Return latitude value of this external event.""" return self._attr_latitude - @property + @cached_property def longitude(self) -> float | None: """Return longitude value of this external event.""" return self._attr_longitude diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index 541d2e0b89d9e8..dd324492d73393 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -18,14 +18,14 @@ from .coordinator import GeocachingDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class GeocachingRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[GeocachingStatus], str | int | None] -@dataclass +@dataclass(frozen=True) class GeocachingSensorEntityDescription( SensorEntityDescription, GeocachingRequiredKeysMixin ): diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json index fd431860cd201c..9989af9a75c5a5 100644 --- a/homeassistant/components/geocaching/strings.json +++ b/homeassistant/components/geocaching/strings.json @@ -18,8 +18,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "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]" + "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/gios/manifest.json b/homeassistant/components/gios/manifest.json index 18ea52fc15fade..2e33bc6741e063 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.1"] + "requirements": ["gios==3.2.2"] } diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index f5bbdb06198e9a..99c1775beef6c3 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -42,17 +42,11 @@ _LOGGER = logging.getLogger(__name__) -@dataclass -class GiosSensorRequiredKeysMixin: - """Class for GIOS entity required keys.""" - - value: Callable[[GiosSensors], StateType] - - -@dataclass -class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKeysMixin): +@dataclass(frozen=True, kw_only=True) +class GiosSensorEntityDescription(SensorEntityDescription): """Class describing GIOS sensor entities.""" + value: Callable[[GiosSensors], StateType] subkey: str | None = None diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 9afbf80297c7a6..c90caf0fc894ca 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -from typing import Any +from contextlib import suppress +from typing import TYPE_CHECKING, Any from aiogithubapi import ( GitHubAPI, @@ -15,22 +16,16 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import FlowResult, UnknownFlow from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) import homeassistant.helpers.config_validation as cv -from .const import ( - CLIENT_ID, - CONF_ACCESS_TOKEN, - CONF_REPOSITORIES, - DEFAULT_REPOSITORIES, - DOMAIN, - LOGGER, -) +from .const import CLIENT_ID, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, LOGGER async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]: @@ -124,19 +119,27 @@ async def async_step_device( """Handle device steps.""" async def _wait_for_login() -> None: - # mypy is not aware that we can't get here without having these set already - assert self._device is not None - assert self._login_device is not None + if TYPE_CHECKING: + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + assert self._login_device is not None try: response = await self._device.activation( device_code=self._login_device.device_code ) self._login = response.data + finally: - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) + + async def _progress(): + # If the user closes the dialog the flow will no longer exist and it will raise UnknownFlow + with suppress(UnknownFlow): + await self.hass.config_entries.flow.async_configure( + flow_id=self.flow_id + ) + + self.hass.async_create_task(_progress()) if not self._device: self._device = GitHubDeviceAPI( @@ -145,31 +148,33 @@ async def _wait_for_login() -> None: **{"client_name": SERVER_SOFTWARE}, ) - try: - response = await self._device.register() - self._login_device = response.data - except GitHubException as exception: - LOGGER.exception(exception) - return self.async_abort(reason="could_not_register") + try: + response = await self._device.register() + self._login_device = response.data + except GitHubException as exception: + LOGGER.exception(exception) + return self.async_abort(reason="could_not_register") - if not self.login_task: + if self.login_task is None: self.login_task = self.hass.async_create_task(_wait_for_login()) - return self.async_show_progress( - step_id="device", - progress_action="wait_for_device", - description_placeholders={ - "url": OAUTH_USER_LOGIN, - "code": self._login_device.user_code, - }, - ) - try: - await self.login_task - except GitHubException as exception: - LOGGER.exception(exception) - return self.async_show_progress_done(next_step_id="could_not_register") + if self.login_task.done(): + if self.login_task.exception(): + return self.async_show_progress_done(next_step_id="could_not_register") + return self.async_show_progress_done(next_step_id="repositories") + + if TYPE_CHECKING: + # mypy is not aware that we can't get here without having this set already + assert self._login_device is not None - return self.async_show_progress_done(next_step_id="repositories") + return self.async_show_progress( + step_id="device", + progress_action="wait_for_device", + description_placeholders={ + "url": OAUTH_USER_LOGIN, + "code": self._login_device.user_code, + }, + ) async def async_step_repositories( self, @@ -177,8 +182,9 @@ async def async_step_repositories( ) -> FlowResult: """Handle repositories step.""" - # mypy is not aware that we can't get here without having this set already - assert self._login is not None + if TYPE_CHECKING: + # mypy is not aware that we can't get here without having this set already + assert self._login is not None if not user_input: repositories = await get_repositories(self.hass, self._login.access_token) @@ -214,6 +220,13 @@ def async_get_options_flow( """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) + @callback + def async_remove(self) -> None: + """Handle remove handler callback.""" + if self.login_task and not self.login_task.done(): + # Clean up login task if it's still running + self.login_task.cancel() + class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for GitHub.""" diff --git a/homeassistant/components/github/const.py b/homeassistant/components/github/const.py index a186f4684b37ad..d01656ee8ae967 100644 --- a/homeassistant/components/github/const.py +++ b/homeassistant/components/github/const.py @@ -13,7 +13,6 @@ DEFAULT_REPOSITORIES = ["home-assistant/core", "esphome/esphome"] FALLBACK_UPDATE_INTERVAL = timedelta(hours=1, minutes=30) -CONF_ACCESS_TOKEN = "access_token" CONF_REPOSITORIES = "repositories" diff --git a/homeassistant/components/github/diagnostics.py b/homeassistant/components/github/diagnostics.py index c2546d636b8281..1562649734462c 100644 --- a/homeassistant/components/github/diagnostics.py +++ b/homeassistant/components/github/diagnostics.py @@ -6,13 +6,14 @@ from aiogithubapi import GitHubAPI, GitHubException from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -from .const import CONF_ACCESS_TOKEN, DOMAIN +from .const import DOMAIN from .coordinator import GitHubDataUpdateCoordinator diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index d497700f5db753..cec0e6b763f063 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -22,14 +22,14 @@ from .coordinator import GitHubDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class BaseEntityDescriptionMixin: """Mixin for required GitHub base description keys.""" value_fn: Callable[[dict[str, Any]], StateType] -@dataclass +@dataclass(frozen=True) class BaseEntityDescription(SensorEntityDescription): """Describes GitHub sensor entity default overrides.""" @@ -38,7 +38,7 @@ class BaseEntityDescription(SensorEntityDescription): avabl_fn: Callable[[dict[str, Any]], bool] = lambda data: True -@dataclass +@dataclass(frozen=True) class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescriptionMixin): """Describes GitHub issue sensor entity.""" diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index bda1baf797af55..1c03f8c1dbfe04 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -1,13 +1,34 @@ """The Glances component.""" +import logging from typing import Any from glances_api import Glances +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiError, + GlancesApiNoDataAvailable, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_VERIFY_SSL, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, + HomeAssistantError, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN from .coordinator import GlancesDataUpdateCoordinator @@ -16,10 +37,19 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Glances from config entry.""" - api = get_api(hass, dict(config_entry.data)) + try: + api = await get_api(hass, dict(config_entry.data)) + except GlancesApiAuthorizationError as err: + raise ConfigEntryAuthFailed from err + except GlancesApiError as err: + raise ConfigEntryNotReady from err + except ServerVersionMismatch as err: + raise ConfigEntryError(err) from err coordinator = GlancesDataUpdateCoordinator(hass, config_entry, api) await coordinator.async_config_entry_first_refresh() @@ -39,8 +69,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances: +async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances: """Return the api from glances_api.""" - entry_data.pop(CONF_NAME, None) httpx_client = get_async_client(hass, verify_ssl=entry_data[CONF_VERIFY_SSL]) - return Glances(httpx_client=httpx_client, **entry_data) + for version in (3, 2): + api = Glances( + host=entry_data[CONF_HOST], + port=entry_data[CONF_PORT], + version=version, + ssl=entry_data[CONF_SSL], + username=entry_data.get(CONF_USERNAME), + password=entry_data.get(CONF_PASSWORD), + httpx_client=httpx_client, + ) + try: + await api.get_ha_sensor_data() + except GlancesApiNoDataAvailable as err: + _LOGGER.debug("Failed to connect to Glances API v%s: %s", version, err) + continue + if version == 2: + async_create_issue( + hass, + DOMAIN, + "deprecated_version", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_version", + ) + _LOGGER.debug("Connected to Glances API v%s", version) + return api + raise ServerVersionMismatch("Could not connect to Glances API version 2 or 3") + + +class ServerVersionMismatch(HomeAssistantError): + """Raise exception if we fail to connect to Glances API.""" diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 72555b629d7ccd..81d3a1187290f5 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -21,15 +21,8 @@ ) from homeassistant.data_entry_flow import FlowResult -from . import get_api -from .const import ( - CONF_VERSION, - DEFAULT_HOST, - DEFAULT_PORT, - DEFAULT_VERSION, - DOMAIN, - SUPPORTED_VERSIONS, -) +from . import ServerVersionMismatch, get_api +from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN DATA_SCHEMA = vol.Schema( { @@ -37,7 +30,6 @@ vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): int, - vol.Required(CONF_VERSION, default=DEFAULT_VERSION): vol.In(SUPPORTED_VERSIONS), vol.Optional(CONF_SSL, default=False): bool, vol.Optional(CONF_VERIFY_SSL, default=False): bool, } @@ -65,9 +57,8 @@ async def async_step_reauth_confirm( assert self._reauth_entry if user_input is not None: user_input = {**self._reauth_entry.data, **user_input} - api = get_api(self.hass, user_input) try: - await api.get_ha_sensor_data() + await get_api(self.hass, user_input) except GlancesApiAuthorizationError: errors["base"] = "invalid_auth" except GlancesApiConnectionError: @@ -101,12 +92,11 @@ async def async_step_user( self._async_abort_entries_match( {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) - api = get_api(self.hass, user_input) try: - await api.get_ha_sensor_data() + await get_api(self.hass, user_input) except GlancesApiAuthorizationError: errors["base"] = "invalid_auth" - except GlancesApiConnectionError: + except (GlancesApiConnectionError, ServerVersionMismatch): errors["base"] = "cannot_connect" else: return self.async_create_entry( diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 37da60bdea80ec..f0477a30463844 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -8,9 +8,6 @@ DEFAULT_HOST = "localhost" DEFAULT_PORT = 61208 -DEFAULT_VERSION = 3 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) -SUPPORTED_VERSIONS = [2, 3] - CPU_ICON = f"mdi:cpu-{64 if sys.maxsize > 2**32 else 32}-bit" diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index d90f7b8274cbe5..d022995b7869b4 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/glances", "iot_class": "local_polling", "loggers": ["glances_api"], - "requirements": ["glances-api==0.4.3"] + "requirements": ["glances-api==0.5.0"] } diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 78aa5ffbf0a16f..a3578bf6f6613a 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -30,7 +30,7 @@ from .const import CPU_ICON, DOMAIN -@dataclass +@dataclass(frozen=True) class GlancesSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -38,7 +38,7 @@ class GlancesSensorEntityDescriptionMixin: name_suffix: str -@dataclass +@dataclass(frozen=True) class GlancesSensorEntityDescription( SensorEntityDescription, GlancesSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index fdd0c44b31ba9b..7e69e7f7912f6c 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -7,9 +7,11 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", - "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": { @@ -27,5 +29,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "issues": { + "deprecated_version": { + "title": "Glances servers with version 2 is deprecated", + "description": "Glances servers with version 2 is deprecated and will not be supported in future versions of HA. It is recommended to update your server to Glances version 3 then reload the integration." + } } } diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index d94f52196077f3..c6d85bd4c1018f 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/button.py b/homeassistant/components/goodwe/button.py index 12cad42547d11e..0aebdb8c0734a1 100644 --- a/homeassistant/components/goodwe/button.py +++ b/homeassistant/components/goodwe/button.py @@ -18,14 +18,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class GoodweButtonEntityDescriptionRequired: """Required attributes of GoodweButtonEntityDescription.""" action: Callable[[Inverter], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class GoodweButtonEntityDescription( ButtonEntityDescription, GoodweButtonEntityDescriptionRequired ): diff --git a/homeassistant/components/goodwe/diagnostics.py b/homeassistant/components/goodwe/diagnostics.py new file mode 100644 index 00000000000000..285036c0254628 --- /dev/null +++ b/homeassistant/components/goodwe/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for Goodwe.""" +from __future__ import annotations + +from typing import Any + +from goodwe import Inverter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, KEY_INVERTER + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + inverter: Inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] + + diagnostics_data = { + "config_entry": config_entry.as_dict(), + "inverter": { + "model_name": inverter.model_name, + "rated_power": inverter.rated_power, + "firmware": inverter.firmware, + "arm_firmware": inverter.arm_firmware, + "dsp1_version": inverter.dsp1_version, + "dsp2_version": inverter.dsp2_version, + "dsp_svn_version": inverter.dsp_svn_version, + "arm_version": inverter.arm_version, + "arm_svn_version": inverter.arm_svn_version, + }, + } + + return diagnostics_data diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index a3e4190f30946b..d92f6ab8fd09ac 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class GoodweNumberEntityDescriptionBase: """Required values when describing Goodwe number entities.""" @@ -32,7 +32,7 @@ class GoodweNumberEntityDescriptionBase: filter: Callable[[Inverter], bool] -@dataclass +@dataclass(frozen=True) class GoodweNumberEntityDescription( NumberEntityDescription, GoodweNumberEntityDescriptionBase ): diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 332280bac5a94d..a43ff971a9a8e8 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -75,16 +75,16 @@ } -@dataclass +@dataclass(frozen=True) 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/calendar.py b/homeassistant/components/google/calendar.py index bd0fe18912e401..3e34a7234a47e9 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -521,8 +521,13 @@ async def async_delete_event( def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" rrule: str | None = None - if len(event.recurrence) == 1: - rrule = event.recurrence[0].lstrip(RRULE_PREFIX) + # Home Assistant expects a single RRULE: and all other rule types are unsupported or ignored + if ( + len(event.recurrence) == 1 + and (raw_rule := event.recurrence[0]) + and raw_rule.startswith(RRULE_PREFIX) + ): + rrule = raw_rule.removeprefix(RRULE_PREFIX) return CalendarEvent( uid=event.ical_uuid, recurrence_id=event.id if event.recurring_event_id else None, diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 509100a5174a79..27e462a380ef2f 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==5.0.0", "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 9327009bda3342..4e62b134b0e960 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -24,8 +24,8 @@ "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "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/const.py b/homeassistant/components/google_assistant/const.py index 060f7ce50e5984..431433e2bba352 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -22,6 +22,8 @@ sensor, switch, vacuum, + valve, + water_heater, ) DOMAIN = "google_assistant" @@ -64,6 +66,8 @@ "sensor", "switch", "vacuum", + "valve", + "water_heater", ] # https://developers.google.com/assistant/smarthome/guides @@ -93,6 +97,8 @@ TYPE_TV = f"{PREFIX_TYPES}TV" TYPE_WINDOW = f"{PREFIX_TYPES}WINDOW" TYPE_VACUUM = f"{PREFIX_TYPES}VACUUM" +TYPE_VALVE = f"{PREFIX_TYPES}VALVE" +TYPE_WATERHEATER = f"{PREFIX_TYPES}WATERHEATER" SERVICE_REQUEST_SYNC = "request_sync" HOMEGRAPH_URL = "https://homegraph.googleapis.com/" @@ -147,6 +153,8 @@ sensor.DOMAIN: TYPE_SENSOR, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, + valve.DOMAIN: TYPE_VALVE, + water_heater.DOMAIN: TYPE_WATERHEATER, } DEVICE_CLASS_TO_GOOGLE_TYPES = { diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index b2cda5522ee684..c89925664e0c2c 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -15,7 +15,7 @@ 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 @@ @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 @@ def sync_serialize(self, agent_user_id, instance_uuid): 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 3c7ac043441ef8..e36f6a1ca8787f 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/trait.py b/homeassistant/components/google_assistant/trait.py index 33f0d7a3329666..9b8a95f0b4ab2a 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -29,6 +29,8 @@ sensor, switch, vacuum, + valve, + water_heater, ) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.camera import CameraEntityFeature @@ -40,6 +42,8 @@ from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.valve import ValveEntityFeature +from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_BATTERY_LEVEL, @@ -139,6 +143,7 @@ COMMAND_BRIGHTNESS_ABSOLUTE = f"{PREFIX_COMMANDS}BrightnessAbsolute" COMMAND_COLOR_ABSOLUTE = f"{PREFIX_COMMANDS}ColorAbsolute" COMMAND_ACTIVATE_SCENE = f"{PREFIX_COMMANDS}ActivateScene" +COMMAND_SET_TEMPERATURE = f"{PREFIX_COMMANDS}SetTemperature" COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( f"{PREFIX_COMMANDS}ThermostatTemperatureSetpoint" ) @@ -177,6 +182,65 @@ FAN_SPEED_MAX_SPEED_COUNT = 5 +COVER_VALVE_STATES = { + cover.DOMAIN: { + "closed": cover.STATE_CLOSED, + "closing": cover.STATE_CLOSING, + "open": cover.STATE_OPEN, + "opening": cover.STATE_OPENING, + }, + valve.DOMAIN: { + "closed": valve.STATE_CLOSED, + "closing": valve.STATE_CLOSING, + "open": valve.STATE_OPEN, + "opening": valve.STATE_OPENING, + }, +} + +SERVICE_STOP_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_STOP_COVER, + valve.DOMAIN: valve.SERVICE_STOP_VALVE, +} +SERVICE_OPEN_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_OPEN_COVER, + valve.DOMAIN: valve.SERVICE_OPEN_VALVE, +} +SERVICE_CLOSE_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_CLOSE_COVER, + valve.DOMAIN: valve.SERVICE_CLOSE_VALVE, +} +SERVICE_TOGGLE_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_TOGGLE, + valve.DOMAIN: valve.SERVICE_TOGGLE, +} +SERVICE_SET_POSITION_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_SET_COVER_POSITION, + valve.DOMAIN: valve.SERVICE_SET_VALVE_POSITION, +} + +COVER_VALVE_CURRENT_POSITION = { + cover.DOMAIN: cover.ATTR_CURRENT_POSITION, + valve.DOMAIN: valve.ATTR_CURRENT_POSITION, +} + +COVER_VALVE_POSITION = { + cover.DOMAIN: cover.ATTR_POSITION, + valve.DOMAIN: valve.ATTR_POSITION, +} + +COVER_VALVE_SET_POSITION_FEATURE = { + cover.DOMAIN: CoverEntityFeature.SET_POSITION, + valve.DOMAIN: ValveEntityFeature.SET_POSITION, +} +COVER_VALVE_STOP_FEATURE = { + cover.DOMAIN: CoverEntityFeature.STOP, + valve.DOMAIN: ValveEntityFeature.STOP, +} + +COVER_VALVE_DOMAINS = {cover.DOMAIN, valve.DOMAIN} + +FRIENDLY_DOMAIN = {cover.DOMAIN: "Cover", valve.DOMAIN: "Valve"} + _TraitT = TypeVar("_TraitT", bound="_Trait") @@ -417,6 +481,9 @@ class OnOffTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" + if domain == water_heater.DOMAIN and features & WaterHeaterEntityFeature.ON_OFF: + return True + return domain in ( group.DOMAIN, input_boolean.DOMAIN, @@ -787,7 +854,10 @@ def supported(domain, features, device_class, _): if domain == vacuum.DOMAIN: return True - if domain == cover.DOMAIN and features & CoverEntityFeature.STOP: + if ( + domain in COVER_VALVE_DOMAINS + and features & COVER_VALVE_STOP_FEATURE[domain] + ): return True return False @@ -801,7 +871,7 @@ def sync_attributes(self): & VacuumEntityFeature.PAUSE != 0 } - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: return {} def query_attributes(self): @@ -815,16 +885,22 @@ def query_attributes(self): "isPaused": state == vacuum.STATE_PAUSED, } - if domain == cover.DOMAIN: - return {"isRunning": state in (cover.STATE_CLOSING, cover.STATE_OPENING)} + if domain in COVER_VALVE_DOMAINS: + return { + "isRunning": state + in ( + COVER_VALVE_STATES[domain]["closing"], + COVER_VALVE_STATES[domain]["opening"], + ) + } async def execute(self, command, data, params, challenge): """Execute a StartStop command.""" domain = self.state.domain if domain == vacuum.DOMAIN: return await self._execute_vacuum(command, data, params, challenge) - if domain == cover.DOMAIN: - return await self._execute_cover(command, data, params, challenge) + if domain in COVER_VALVE_DOMAINS: + return await self._execute_cover_or_valve(command, data, params, challenge) async def _execute_vacuum(self, command, data, params, challenge): """Execute a StartStop command.""" @@ -863,28 +939,34 @@ async def _execute_vacuum(self, command, data, params, challenge): context=data.context, ) - async def _execute_cover(self, command, data, params, challenge): + async def _execute_cover_or_valve(self, command, data, params, challenge): """Execute a StartStop command.""" + domain = self.state.domain if command == COMMAND_STARTSTOP: if params["start"] is False: if self.state.state in ( - cover.STATE_CLOSING, - cover.STATE_OPENING, + COVER_VALVE_STATES[domain]["closing"], + COVER_VALVE_STATES[domain]["opening"], ) or self.state.attributes.get(ATTR_ASSUMED_STATE): await self.hass.services.async_call( - self.state.domain, - cover.SERVICE_STOP_COVER, + domain, + SERVICE_STOP_COVER_VALVE[domain], {ATTR_ENTITY_ID: self.state.entity_id}, blocking=not self.config.should_report_state, context=data.context, ) else: raise SmartHomeError( - ERR_ALREADY_STOPPED, "Cover is already stopped" + ERR_ALREADY_STOPPED, + f"{FRIENDLY_DOMAIN[domain]} is already stopped", ) else: - raise SmartHomeError( - ERR_NOT_SUPPORTED, "Starting a cover is not supported" + await self.hass.services.async_call( + domain, + SERVICE_TOGGLE_COVER_VALVE[domain], + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, ) else: raise SmartHomeError( @@ -894,38 +976,97 @@ async def _execute_cover(self, command, data, params, challenge): @register_trait class TemperatureControlTrait(_Trait): - """Trait for devices (other than thermostats) that support controlling temperature. Workaround for Temperature sensors. + """Trait for devices (other than thermostats) that support controlling temperature. + + Control the target temperature of water heaters. + Offers a workaround for Temperature sensors by setting queryOnlyTemperatureControl + in the response. https://developers.google.com/assistant/smarthome/traits/temperaturecontrol """ name = TRAIT_TEMPERATURE_CONTROL + commands = [ + COMMAND_SET_TEMPERATURE, + ] + @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" return ( + domain == water_heater.DOMAIN + and features & WaterHeaterEntityFeature.TARGET_TEMPERATURE + ) or ( domain == sensor.DOMAIN and device_class == sensor.SensorDeviceClass.TEMPERATURE ) def sync_attributes(self): """Return temperature attributes for a sync request.""" - return { - "temperatureUnitForUX": _google_temp_unit( - self.hass.config.units.temperature_unit - ), - "queryOnlyTemperatureControl": True, - "temperatureRange": { + response = {} + domain = self.state.domain + attrs = self.state.attributes + unit = self.hass.config.units.temperature_unit + response["temperatureUnitForUX"] = _google_temp_unit(unit) + + if domain == water_heater.DOMAIN: + min_temp = round( + TemperatureConverter.convert( + float(attrs[water_heater.ATTR_MIN_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) + ) + max_temp = round( + TemperatureConverter.convert( + float(attrs[water_heater.ATTR_MAX_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) + ) + response["temperatureRange"] = { + "minThresholdCelsius": min_temp, + "maxThresholdCelsius": max_temp, + } + else: + response["queryOnlyTemperatureControl"] = True + response["temperatureRange"] = { "minThresholdCelsius": -100, "maxThresholdCelsius": 100, - }, - } + } + + return response def query_attributes(self): """Return temperature states.""" response = {} + domain = self.state.domain unit = self.hass.config.units.temperature_unit + if domain == water_heater.DOMAIN: + target_temp = self.state.attributes[water_heater.ATTR_TEMPERATURE] + current_temp = self.state.attributes[water_heater.ATTR_CURRENT_TEMPERATURE] + if target_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + response["temperatureSetpointCelsius"] = round( + TemperatureConverter.convert( + float(target_temp), + unit, + UnitOfTemperature.CELSIUS, + ), + 1, + ) + if current_temp is not None: + response["temperatureAmbientCelsius"] = round( + TemperatureConverter.convert( + float(current_temp), + unit, + UnitOfTemperature.CELSIUS, + ), + 1, + ) + return response + + # domain == sensor.DOMAIN current_temp = self.state.state if current_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE): temp = round( @@ -940,8 +1081,35 @@ def query_attributes(self): return response async def execute(self, command, data, params, challenge): - """Unsupported.""" - raise SmartHomeError(ERR_NOT_SUPPORTED, "Execute is not supported by sensor") + """Execute a temperature point or mode command.""" + # All sent in temperatures are always in Celsius + domain = self.state.domain + unit = self.hass.config.units.temperature_unit + + if domain == water_heater.DOMAIN and command == COMMAND_SET_TEMPERATURE: + min_temp = self.state.attributes[water_heater.ATTR_MIN_TEMP] + max_temp = self.state.attributes[water_heater.ATTR_MAX_TEMP] + temp = TemperatureConverter.convert( + params["temperature"], UnitOfTemperature.CELSIUS, unit + ) + if unit == UnitOfTemperature.FAHRENHEIT: + temp = round(temp) + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + f"Temperature should be between {min_temp} and {max_temp}", + ) + + await self.hass.services.async_call( + water_heater.DOMAIN, + water_heater.SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: temp}, + blocking=not self.config.should_report_state, + context=data.context, + ) + return + + raise SmartHomeError(ERR_NOT_SUPPORTED, f"Execute is not supported by {domain}") @register_trait @@ -1696,6 +1864,12 @@ def supported(domain, features, device_class, _): if domain == light.DOMAIN and features & LightEntityFeature.EFFECT: return True + if ( + domain == water_heater.DOMAIN + and features & WaterHeaterEntityFeature.OPERATION_MODE + ): + return True + if domain != media_player.DOMAIN: return False @@ -1736,6 +1910,7 @@ def sync_attributes(self): (select.DOMAIN, select.ATTR_OPTIONS, "option"), (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"), (light.DOMAIN, light.ATTR_EFFECT_LIST, "effect"), + (water_heater.DOMAIN, water_heater.ATTR_OPERATION_LIST, "operation mode"), ): if self.state.domain != domain: continue @@ -1769,6 +1944,11 @@ def query_attributes(self): elif self.state.domain == humidifier.DOMAIN: if ATTR_MODE in attrs: mode_settings["mode"] = attrs.get(ATTR_MODE) + elif self.state.domain == water_heater.DOMAIN: + if water_heater.ATTR_OPERATION_MODE in attrs: + mode_settings["operation mode"] = attrs.get( + water_heater.ATTR_OPERATION_MODE + ) elif self.state.domain == light.DOMAIN and ( effect := attrs.get(light.ATTR_EFFECT) ): @@ -1840,6 +2020,20 @@ async def execute(self, command, data, params, challenge): ) return + if self.state.domain == water_heater.DOMAIN: + requested_mode = settings["operation mode"] + await self.hass.services.async_call( + water_heater.DOMAIN, + water_heater.SERVICE_SET_OPERATION_MODE, + { + water_heater.ATTR_OPERATION_MODE: requested_mode, + ATTR_ENTITY_ID: self.state.entity_id, + }, + blocking=not self.config.should_report_state, + context=data.context, + ) + return + if self.state.domain == light.DOMAIN: requested_effect = settings["effect"] await self.hass.services.async_call( @@ -1963,7 +2157,7 @@ class OpenCloseTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: return True return domain == binary_sensor.DOMAIN and device_class in ( @@ -1998,6 +2192,17 @@ def sync_attributes(self): and features & CoverEntityFeature.CLOSE == 0 ): response["queryOnlyOpenClose"] = True + elif ( + self.state.domain == valve.DOMAIN + and features & ValveEntityFeature.SET_POSITION == 0 + ): + response["discreteOnlyOpenClose"] = True + + if ( + features & ValveEntityFeature.OPEN == 0 + and features & ValveEntityFeature.CLOSE == 0 + ): + response["queryOnlyOpenClose"] = True if self.state.attributes.get(ATTR_ASSUMED_STATE): response["commandOnlyOpenClose"] = True @@ -2016,17 +2221,17 @@ def query_attributes(self): if self.state.attributes.get(ATTR_ASSUMED_STATE): return response - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: if self.state.state == STATE_UNKNOWN: raise SmartHomeError( ERR_NOT_SUPPORTED, "Querying state is not supported" ) - position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + position = self.state.attributes.get(COVER_VALVE_CURRENT_POSITION[domain]) if position is not None: response["openPercent"] = position - elif self.state.state != cover.STATE_CLOSED: + elif self.state.state != COVER_VALVE_STATES[domain]["closed"]: response["openPercent"] = 100 else: response["openPercent"] = 0 @@ -2044,11 +2249,13 @@ async def execute(self, command, data, params, challenge): domain = self.state.domain features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: svc_params = {ATTR_ENTITY_ID: self.state.entity_id} should_verify = False if command == COMMAND_OPENCLOSE_RELATIVE: - position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + position = self.state.attributes.get( + COVER_VALVE_CURRENT_POSITION[domain] + ) if position is None: raise SmartHomeError( ERR_NOT_SUPPORTED, @@ -2059,16 +2266,16 @@ async def execute(self, command, data, params, challenge): position = params["openPercent"] if position == 0: - service = cover.SERVICE_CLOSE_COVER + service = SERVICE_CLOSE_COVER_VALVE[domain] should_verify = False elif position == 100: - service = cover.SERVICE_OPEN_COVER + service = SERVICE_OPEN_COVER_VALVE[domain] should_verify = True - elif features & CoverEntityFeature.SET_POSITION: - service = cover.SERVICE_SET_COVER_POSITION + elif features & COVER_VALVE_SET_POSITION_FEATURE[domain]: + service = SERVICE_SET_POSITION_COVER_VALVE[domain] if position > 0: should_verify = True - svc_params[cover.ATTR_POSITION] = position + svc_params[COVER_VALVE_POSITION[domain]] = position else: raise SmartHomeError( ERR_NOT_SUPPORTED, "No support for partial open close" @@ -2082,7 +2289,7 @@ async def execute(self, command, data, params, challenge): _verify_pin_challenge(data, self.state, challenge) await self.hass.services.async_call( - cover.DOMAIN, + domain, service, svc_params, blocking=not self.config.should_report_state, diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index fa86e207a9cb22..d5d1d8854277eb 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -23,8 +23,8 @@ "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "unknown": "[%key:common::config_flow::error::unknown%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "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 1154c7132d21da..c507e0c046de6f 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -88,7 +88,7 @@ async def async_process( 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_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 94639177a42f64..fea023c604e71c 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -129,7 +129,7 @@ async def async_step_init( def google_generative_ai_config_option_schema( - options: MappingProxyType[str, Any] + options: MappingProxyType[str, Any], ) -> dict: """Return a schema for Google Generative AI completion options.""" if not options: diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 65d9e0b38943fc..5bafa9c43de18d 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-generativeai==0.1.0"] + "requirements": ["google-generativeai==0.3.1"] } diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index 3ed1c2377d5f6c..142e8f039d2953 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -24,8 +24,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "wrong_account": "Wrong account: Please authenticate with {email}.", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "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 ea327097d880ab..e498e36723e7a5 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -23,8 +23,8 @@ "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "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 5dd7156702f0ef..2658fdedc5993f 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -126,6 +126,21 @@ def response_handler(_, response, exception: HttpError) -> None: ) await self._execute(batch) + async def move( + self, + task_list_id: str, + task_id: str, + previous: str | None, + ) -> None: + """Move a task resource to a specific position within the task list.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().move( + tasklist=task_list_id, + task=task_id, + previous=previous, + ) + await self._execute(cmd) + async def _execute(self, request: HttpRequest | BatchHttpRequest) -> Any: try: result = await self._hass.async_add_executor_job(request.execute) diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index d730f4cb770eb5..2cf15f0d93dde8 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -19,8 +19,8 @@ "access_not_configured": "Unable to access the Google API:\n\n{message}", "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]" + "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 01ceb0349e6bbd..e83b0d39a300c3 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.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 @@ -28,16 +29,40 @@ TODO_STATUS_MAP_INV = {v: k for k, v in TODO_STATUS_MAP.items()} -def _convert_todo_item(item: TodoItem) -> dict[str, str]: +def _convert_todo_item(item: TodoItem) -> dict[str, str | None]: """Convert TodoItem dataclass items to dictionary of attributes the tasks API.""" - result: dict[str, str] = {} - if item.summary is not None: - result["title"] = item.summary + result: dict[str, str | None] = {} + result["title"] = item.summary if item.status is not None: result["status"] = TODO_STATUS_MAP_INV[item.status] + else: + result["status"] = TodoItemStatus.NEEDS_ACTION + if (due := item.due) is not None: + # due API field is a timestamp string, but with only date resolution + result["due"] = dt_util.start_of_local_day(due).isoformat() + else: + result["due"] = None + result["notes"] = item.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: @@ -68,6 +93,9 @@ class GoogleTaskTodoListEntity( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -88,16 +116,7 @@ def todo_items(self) -> list[TodoItem] | None: """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.""" @@ -121,3 +140,23 @@ 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() + + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Re-order a To-do item.""" + await self.coordinator.api.move(self._task_list_id, uid, previous=previous_uid) + 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/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 83e144f6bbdc0b..ec8187d91af1c7 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv @@ -23,7 +23,6 @@ CONF_AVOID, CONF_DEPARTURE_TIME, CONF_DESTINATION, - CONF_LANGUAGE, CONF_ORIGIN, CONF_TIME, CONF_TIME_TYPE, diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index 0535e295b93a0e..041858d948fff6 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -7,7 +7,6 @@ CONF_OPTIONS = "options" CONF_ORIGIN = "origin" CONF_TRAVEL_MODE = "travel_mode" -CONF_LANGUAGE = "language" CONF_AVOID = "avoid" CONF_UNITS = "units" CONF_ARRIVAL_TIME = "arrival_time" diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 6bf552b824be80..f90cc028fdfbc3 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -42,7 +42,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) -@dataclass +@dataclass(frozen=True) class GoogleWifiRequiredKeysMixin: """Mixin for required keys.""" @@ -50,7 +50,7 @@ class GoogleWifiRequiredKeysMixin: sensor_key: str -@dataclass +@dataclass(frozen=True) class GoogleWifiSensorEntityDescription( SensorEntityDescription, GoogleWifiRequiredKeysMixin ): diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index ff3438ed53fbdd..13e93d780b26c0 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -11,7 +11,6 @@ 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_scan_update(_=None): _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_scan_update(_=None): 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 b14b9cfaba48f4..8d50cdf2aedc74 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -38,21 +38,19 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .bridge import DeviceDataUpdateCoordinator from .const import ( COORDINATORS, DISPATCH_DEVICE_DISCOVERED, - DISPATCHERS, DOMAIN, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, TARGET_TEMPERATURE_STEP, ) +from .entity import GreeEntity _LOGGER = logging.getLogger(__name__) @@ -88,7 +86,7 @@ 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.""" @@ -101,12 +99,12 @@ def init_device(coordinator): 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) ) -class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateEntity): +class GreeClimateEntity(GreeEntity, ClimateEntity): """Representation of a Gree HVAC device.""" _attr_precision = PRECISION_WHOLE @@ -121,19 +119,12 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE _attr_preset_modes = PRESET_MODES _attr_fan_modes = [*FAN_MODES_REVERSE] _attr_swing_modes = SWING_MODES + _attr_name = None def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" super().__init__(coordinator) - self._attr_name = coordinator.device.device_info.name - mac = coordinator.device.device_info.mac - self._attr_unique_id = mac - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, mac)}, - identifiers={(DOMAIN, mac)}, - manufacturer="Gree", - name=self._attr_name, - ) + self._attr_unique_id = coordinator.device.device_info.mac units = self.coordinator.device.temperature_units if units == TemperatureUnits.C: self._attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index b4df7a1acde5d8..46479210921411 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/entity.py b/homeassistant/components/gree/entity.py index fd1b80ef90dd55..c965ad45721bf0 100644 --- a/homeassistant/components/gree/entity.py +++ b/homeassistant/components/gree/entity.py @@ -9,13 +9,15 @@ class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Generic Gree entity (base class).""" - def __init__(self, coordinator: DeviceDataUpdateCoordinator, desc: str) -> None: + _attr_has_entity_name = True + + def __init__( + self, coordinator: DeviceDataUpdateCoordinator, desc: str | None = None + ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._desc = desc name = coordinator.device.device_info.name mac = coordinator.device.device_info.mac - self._attr_name = f"{name} {desc}" self._attr_unique_id = f"{mac}_{desc}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, mac)}, diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index ad8f0f41ae7b29..45911433b92505 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -9,5 +9,24 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "switch": { + "light": { + "name": "Panel light" + }, + "quiet": { + "name": "Quiet" + }, + "fresh_air": { + "name": "Fresh air" + }, + "xfan": { + "name": "XFan" + }, + "health_mode": { + "name": "Health mode" + } + } } } diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 68c11ad6e1f458..07e88223306073 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -17,27 +17,18 @@ 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 -@dataclass -class GreeRequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True, frozen=True) +class GreeSwitchEntityDescription(SwitchEntityDescription): + """Describes a Gree switch entity.""" get_value_fn: Callable[[Device], bool] set_value_fn: Callable[[Device, bool], None] -@dataclass -class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): - """Describes Gree switch entity.""" - - # GreeSwitch does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - - def _set_light(device: Device, value: bool) -> None: """Typed helper to set device light property.""" device.light = value @@ -66,33 +57,33 @@ def _set_anion(device: Device, value: bool) -> None: GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( GreeSwitchEntityDescription( icon="mdi:lightbulb", - name="Panel Light", - key="light", + key="Panel Light", + translation_key="light", get_value_fn=lambda d: d.light, set_value_fn=_set_light, ), GreeSwitchEntityDescription( - name="Quiet", - key="quiet", + key="Quiet", + translation_key="quiet", get_value_fn=lambda d: d.quiet, set_value_fn=_set_quiet, ), GreeSwitchEntityDescription( - name="Fresh Air", - key="fresh_air", + key="Fresh Air", + translation_key="fresh_air", get_value_fn=lambda d: d.fresh_air, set_value_fn=_set_fresh_air, ), GreeSwitchEntityDescription( - name="XFan", - key="xfan", + key="XFan", + translation_key="xfan", get_value_fn=lambda d: d.xfan, set_value_fn=_set_xfan, ), GreeSwitchEntityDescription( icon="mdi:pine-tree", - name="Health mode", - key="anion", + key="Health mode", + translation_key="health_mode", get_value_fn=lambda d: d.anion, set_value_fn=_set_anion, entity_registry_enabled_default=False, @@ -102,7 +93,7 @@ def _set_anion(device: Device, value: bool) -> None: 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 +110,7 @@ def init_device(coordinator): 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) ) @@ -134,7 +125,7 @@ def __init__(self, coordinator, description: GreeSwitchEntityDescription) -> Non """Initialize the Gree device.""" self.entity_description = description - super().__init__(coordinator, description.name) + super().__init__(coordinator, description.key) @property def is_on(self) -> bool: diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index ae246041db9cd5..894a20629eea7a 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -3,10 +3,10 @@ from abc import abstractmethod import asyncio -from collections.abc import Callable, Collection, Iterable, Mapping +from collections.abc import Callable, Collection, Mapping from contextvars import ContextVar import logging -from typing import Any, Protocol, cast +from typing import Any, Protocol import voluptuous as vol @@ -19,8 +19,6 @@ CONF_ENTITIES, CONF_ICON, CONF_NAME, - ENTITY_MATCH_ALL, - ENTITY_MATCH_NONE, SERVICE_RELOAD, STATE_OFF, STATE_ON, @@ -41,6 +39,10 @@ EventStateChangedData, async_track_state_change_event, ) +from homeassistant.helpers.group import ( + expand_entity_ids as _expand_entity_ids, + get_entity_ids as _get_entity_ids, +) from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) @@ -167,58 +169,9 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: return False -@bind_hass -def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[str]: - """Return entity_ids with group entity ids replaced by their members. - - Async friendly. - """ - found_ids: list[str] = [] - for entity_id in entity_ids: - if not isinstance(entity_id, str) or entity_id in ( - ENTITY_MATCH_NONE, - ENTITY_MATCH_ALL, - ): - continue - - entity_id = entity_id.lower() - # If entity_id points at a group, expand it - if entity_id.startswith(ENTITY_PREFIX): - child_entities = get_entity_ids(hass, entity_id) - if entity_id in child_entities: - child_entities = list(child_entities) - child_entities.remove(entity_id) - found_ids.extend( - ent_id - for ent_id in expand_entity_ids(hass, child_entities) - if ent_id not in found_ids - ) - elif entity_id not in found_ids: - found_ids.append(entity_id) - - return found_ids - - -@bind_hass -def get_entity_ids( - hass: HomeAssistant, entity_id: str, domain_filter: str | None = None -) -> list[str]: - """Get members of this group. - - Async friendly. - """ - group = hass.states.get(entity_id) - - if not group or ATTR_ENTITY_ID not in group.attributes: - return [] - - entity_ids = group.attributes[ATTR_ENTITY_ID] - if not domain_filter: - return cast(list[str], entity_ids) - - domain_filter = f"{domain_filter.lower()}." - - return [ent_id for ent_id in entity_ids if ent_id.startswith(domain_filter)] +# expand_entity_ids and get_entity_ids are for backwards compatibility only +expand_entity_ids = bind_hass(_expand_entity_ids) +get_entity_ids = bind_hass(_get_entity_ids) @bind_hass @@ -509,7 +462,8 @@ def async_state_changed_listener( self.async_update_supported_features( event.data["entity_id"], event.data["new_state"] ) - preview_callback(*self._async_generate_attributes()) + calculated_state = self._async_calculate_state() + preview_callback(calculated_state.state, calculated_state.attributes) async_state_changed_listener(None) return async_track_state_change_event( diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index bc238519cfa90b..b85fbf32a0d188 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -236,7 +236,8 @@ def async_state_changed_listener( ) -> None: """Handle child updates.""" self.async_update_group_state() - preview_callback(*self._async_generate_attributes()) + calculated_state = self._async_calculate_state() + preview_callback(calculated_state.state, calculated_state.attributes) async_state_changed_listener(None) return async_track_state_change_event( diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 10030ab647fec2..c35c96d38aaa18 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -154,7 +154,7 @@ def async_create_preview_sensor( def calc_min( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate min value.""" val: float | None = None @@ -170,7 +170,7 @@ def calc_min( def calc_max( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate max value.""" val: float | None = None @@ -186,7 +186,7 @@ def calc_max( def calc_mean( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate mean value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) @@ -196,7 +196,7 @@ def calc_mean( def calc_median( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate median value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) @@ -206,7 +206,7 @@ def calc_median( def calc_last( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate last value.""" last_updated: datetime | None = None @@ -223,7 +223,7 @@ def calc_last( def calc_range( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate range value.""" max_result = max((sensor_value for _, sensor_value, _ in sensor_values)) @@ -234,7 +234,7 @@ def calc_range( def calc_sum( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate a sum of values.""" result = 0.0 @@ -245,7 +245,7 @@ def calc_sum( def calc_product( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate a product of values.""" result = 1.0 diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index a21c811af47b0d..d872474f1dab6e 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/growatt_server/sensor_types/sensor_entity_description.py b/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py index cd286e228b441d..cfeb98a382e4eb 100644 --- a/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py +++ b/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py @@ -6,14 +6,14 @@ from homeassistant.components.sensor import SensorEntityDescription -@dataclass +@dataclass(frozen=True) class GrowattRequiredKeysMixin: """Mixin for required keys.""" api_key: str -@dataclass +@dataclass(frozen=True) class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin): """Describes Growatt sensor entity.""" diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index d7a9fe4e83611e..b9f0740ea0c03f 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import cast +from typing import Any, cast from aioguardian import Client from aioguardian.errors import GuardianError @@ -170,7 +170,9 @@ async def async_init_coordinator( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def call_with_data(func: Callable) -> Callable: + def call_with_data( + func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]], + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Hydrate a service call with the appropriate GuardianData object.""" async def wrapper(call: ServiceCall) -> None: @@ -363,27 +365,8 @@ def __init__( """Initialize.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {} self.entity_description = description - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity's underlying data. - - This should be extended by Guardian platforms. - """ - - @callback - def _handle_coordinator_update(self) -> None: - """Respond to a DataUpdateCoordinator update.""" - self._async_update_from_latest_data() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - self._async_update_from_latest_data() - class PairedSensorEntity(GuardianEntity): """Define a Guardian paired sensor entity.""" @@ -408,14 +391,14 @@ def __init__( self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" -@dataclass +@dataclass(frozen=True, kw_only=True) class ValveControllerEntityDescriptionMixin: """Define an entity description mixin for valve controller entities.""" api_category: str -@dataclass +@dataclass(frozen=True, kw_only=True) class ValveControllerEntityDescription( EntityDescription, ValveControllerEntityDescriptionMixin ): diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 7114d33f93a663..6b58e70e45dcd5 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -1,7 +1,9 @@ """Binary sensors for the Elexa Guardian integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import Any from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, @@ -39,24 +41,35 @@ SENSOR_KIND_MOVED = "moved" -@dataclass +@dataclass(frozen=True, kw_only=True) +class PairedSensorBinarySensorDescription(BinarySensorEntityDescription): + """Describe a Guardian paired sensor binary sensor.""" + + is_on_fn: Callable[[dict[str, Any]], bool] + + +@dataclass(frozen=True, kw_only=True) class ValveControllerBinarySensorDescription( BinarySensorEntityDescription, ValveControllerEntityDescription ): """Describe a Guardian valve controller binary sensor.""" + is_on_fn: Callable[[dict[str, Any]], bool] + PAIRED_SENSOR_DESCRIPTIONS = ( - BinarySensorEntityDescription( + PairedSensorBinarySensorDescription( key=SENSOR_KIND_LEAK_DETECTED, translation_key="leak", device_class=BinarySensorDeviceClass.MOISTURE, + is_on_fn=lambda data: data["wet"], ), - BinarySensorEntityDescription( + PairedSensorBinarySensorDescription( key=SENSOR_KIND_MOVED, translation_key="moved", device_class=BinarySensorDeviceClass.MOVING, entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: data["moved"], ), ) @@ -66,6 +79,7 @@ class ValveControllerBinarySensorDescription( translation_key="leak", device_class=BinarySensorDeviceClass.MOISTURE, api_category=API_SYSTEM_ONBOARD_SENSOR_STATUS, + is_on_fn=lambda data: data["wet"], ), ) @@ -84,9 +98,6 @@ async def async_setup_entry( EntityDomainReplacementStrategy( BINARY_SENSOR_DOMAIN, f"{uid}_ap_enabled", - f"switch.guardian_valve_controller_{uid}_onboard_ap", - "2022.12.0", - remove_old_entity=True, ), ), ) @@ -133,7 +144,7 @@ def add_new_paired_sensor(uid: str) -> None: class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity): """Define a binary sensor related to a Guardian valve controller.""" - entity_description: BinarySensorEntityDescription + entity_description: PairedSensorBinarySensorDescription def __init__( self, @@ -146,13 +157,10 @@ def __init__( self._attr_is_on = True - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity's underlying data.""" - if self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: - self._attr_is_on = self.coordinator.data["wet"] - elif self.entity_description.key == SENSOR_KIND_MOVED: - self._attr_is_on = self.coordinator.data["moved"] + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): @@ -171,8 +179,7 @@ def __init__( self._attr_is_on = True - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity.""" - if self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: - self._attr_is_on = self.coordinator.data["wet"] + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index c6363c9bcec798..485de90f1d8b01 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -23,21 +23,14 @@ from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN -@dataclass -class GuardianButtonEntityDescriptionMixin: - """Define an mixin for button entities.""" - - push_action: Callable[[Client], Awaitable] - - -@dataclass +@dataclass(frozen=True, kw_only=True) class ValveControllerButtonDescription( - ButtonEntityDescription, - ValveControllerEntityDescription, - GuardianButtonEntityDescriptionMixin, + ButtonEntityDescription, ValveControllerEntityDescription ): """Describe a Guardian valve controller button.""" + push_action: Callable[[Client], Awaitable] + BUTTON_KIND_REBOOT = "reboot" BUTTON_KIND_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics" diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index c5fc77cc8f910f..85adaddb7f2b51 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -1,7 +1,9 @@ """Sensors for the Elexa Guardian integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -19,6 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import ( GuardianData, @@ -39,25 +42,36 @@ SENSOR_KIND_UPTIME = "uptime" -@dataclass +@dataclass(frozen=True, kw_only=True) +class PairedSensorDescription(SensorEntityDescription): + """Describe a Guardian paired sensor.""" + + value_fn: Callable[[dict[str, Any]], StateType] + + +@dataclass(frozen=True, kw_only=True) class ValveControllerSensorDescription( SensorEntityDescription, ValveControllerEntityDescription ): """Describe a Guardian valve controller sensor.""" + value_fn: Callable[[dict[str, Any]], StateType] + PAIRED_SENSOR_DESCRIPTIONS = ( - SensorEntityDescription( + PairedSensorDescription( key=SENSOR_KIND_BATTERY, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_fn=lambda data: data["battery"], ), - SensorEntityDescription( + PairedSensorDescription( key=SENSOR_KIND_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["temperature"], ), ) VALVE_CONTROLLER_DESCRIPTIONS = ( @@ -67,6 +81,7 @@ class ValveControllerSensorDescription( native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, state_class=SensorStateClass.MEASUREMENT, api_category=API_SYSTEM_ONBOARD_SENSOR_STATUS, + value_fn=lambda data: data["temperature"], ), ValveControllerSensorDescription( key=SENSOR_KIND_UPTIME, @@ -75,6 +90,7 @@ class ValveControllerSensorDescription( entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTime.MINUTES, api_category=API_SYSTEM_DIAGNOSTICS, + value_fn=lambda data: data["uptime"], ), ) @@ -125,15 +141,12 @@ def add_new_paired_sensor(uid: str) -> None: class PairedSensorSensor(PairedSensorEntity, SensorEntity): """Define a binary sensor related to a Guardian valve controller.""" - entity_description: SensorEntityDescription + entity_description: PairedSensorDescription - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity's underlying data.""" - if self.entity_description.key == SENSOR_KIND_BATTERY: - self._attr_native_value = self.coordinator.data["battery"] - elif self.entity_description.key == SENSOR_KIND_TEMPERATURE: - self._attr_native_value = self.coordinator.data["temperature"] + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) class ValveControllerSensor(ValveControllerEntity, SensorEntity): @@ -141,10 +154,7 @@ class ValveControllerSensor(ValveControllerEntity, SensorEntity): entity_description: ValveControllerSensorDescription - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity's underlying data.""" - if self.entity_description.key == SENSOR_KIND_TEMPERATURE: - self._attr_native_value = self.coordinator.data["temperature"] - elif self.entity_description.key == SENSOR_KIND_UPTIME: - self._attr_native_value = self.coordinator.data["uptime"] + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 4e2be5ae17965c..81f06ba435616a 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -1,7 +1,7 @@ """Switches for the Elexa Guardian integration.""" from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from typing import Any @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -28,21 +28,25 @@ SWITCH_KIND_ONBOARD_AP = "onboard_ap" SWITCH_KIND_VALVE = "valve" +ON_STATES = { + "start_opening", + "opening", + "finish_opening", + "opened", +} -@dataclass -class SwitchDescriptionMixin: - """Define an entity description mixin for Guardian switches.""" - off_action: Callable[[Client], Awaitable] - on_action: Callable[[Client], Awaitable] - - -@dataclass +@dataclass(frozen=True, kw_only=True) class ValveControllerSwitchDescription( - SwitchEntityDescription, ValveControllerEntityDescription, SwitchDescriptionMixin + SwitchEntityDescription, ValveControllerEntityDescription ): """Describe a Guardian valve controller switch.""" + extra_state_attributes_fn: Callable[[dict[str, Any]], Mapping[str, Any]] + is_on_fn: Callable[[dict[str, Any]], bool] + off_fn: Callable[[Client], Awaitable] + on_fn: Callable[[Client], Awaitable] + async def _async_disable_ap(client: Client) -> None: """Disable the onboard AP.""" @@ -70,17 +74,29 @@ async def _async_open_valve(client: Client) -> None: translation_key="onboard_access_point", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, + extra_state_attributes_fn=lambda data: { + ATTR_CONNECTED_CLIENTS: data.get("ap_clients"), + ATTR_STATION_CONNECTED: data["station_connected"], + }, api_category=API_WIFI_STATUS, - off_action=_async_disable_ap, - on_action=_async_enable_ap, + is_on_fn=lambda data: data["ap_enabled"], + off_fn=_async_disable_ap, + on_fn=_async_enable_ap, ), ValveControllerSwitchDescription( key=SWITCH_KIND_VALVE, translation_key="valve_controller", icon="mdi:water", api_category=API_VALVE_STATUS, - off_action=_async_close_valve, - on_action=_async_open_valve, + extra_state_attributes_fn=lambda data: { + ATTR_AVG_CURRENT: data["average_current"], + ATTR_INST_CURRENT: data["instantaneous_current"], + ATTR_INST_CURRENT_DDT: data["instantaneous_current_ddt"], + ATTR_TRAVEL_COUNT: data["travel_count"], + }, + is_on_fn=lambda data: data["state"] in ON_STATES, + off_fn=_async_close_valve, + on_fn=_async_open_valve, ), ) @@ -100,13 +116,6 @@ async def async_setup_entry( class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): """Define a switch related to a Guardian valve controller.""" - ON_STATES = { - "start_opening", - "opening", - "finish_opening", - "opened", - } - entity_description: ValveControllerSwitchDescription def __init__( @@ -120,29 +129,15 @@ def __init__( self._client = data.client - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity.""" - if self.entity_description.key == SWITCH_KIND_ONBOARD_AP: - self._attr_extra_state_attributes.update( - { - ATTR_CONNECTED_CLIENTS: self.coordinator.data.get("ap_clients"), - ATTR_STATION_CONNECTED: self.coordinator.data["station_connected"], - } - ) - self._attr_is_on = self.coordinator.data["ap_enabled"] - elif self.entity_description.key == SWITCH_KIND_VALVE: - self._attr_is_on = self.coordinator.data["state"] in self.ON_STATES - self._attr_extra_state_attributes.update( - { - ATTR_AVG_CURRENT: self.coordinator.data["average_current"], - ATTR_INST_CURRENT: self.coordinator.data["instantaneous_current"], - ATTR_INST_CURRENT_DDT: self.coordinator.data[ - "instantaneous_current_ddt" - ], - ATTR_TRAVEL_COUNT: self.coordinator.data["travel_count"], - } - ) + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return entity specific state attributes.""" + return self.entity_description.extra_state_attributes_fn(self.coordinator.data) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" @@ -151,7 +146,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: try: async with self._client: - await self.entity_description.off_action(self._client) + await self.entity_description.off_fn(self._client) except GuardianError as err: raise HomeAssistantError( f'Error while turning "{self.entity_id}" off: {err}' @@ -167,7 +162,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: try: async with self._client: - await self.entity_description.on_action(self._client) + await self.entity_description.on_fn(self._client) except GuardianError as err: raise HomeAssistantError( f'Error while turning "{self.entity_id}" on: {err}' diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index ff41c6e4936ed7..400cd472446fe0 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -29,9 +29,6 @@ class EntityDomainReplacementStrategy: old_domain: str old_unique_id: str - replacement_entity_id: str - breaks_in_ha_version: str - remove_old_entity: bool = True @callback @@ -55,9 +52,8 @@ def async_finish_entity_domain_replacements( continue old_entity_id = registry_entry.entity_id - if strategy.remove_old_entity: - LOGGER.info('Removing old entity: "%s"', old_entity_id) - ent_reg.async_remove(old_entity_id) + LOGGER.info('Removing old entity: "%s"', old_entity_id) + ent_reg.async_remove(old_entity_id) class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]): diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index 9ae22090d7fbdb..f6862ca3c83423 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 acd04596bd5cfe..2d072f11f2c095 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 @@ def is_on(self): 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 @@ async def async_added_to_hass(self) -> None: ) ) ) + 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/__init__.py b/homeassistant/components/hassio/__init__.py index e7ab7aac3c85d9..3dd9b11ae646f0 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -270,6 +270,7 @@ class APIEndpointSettings(NamedTuple): "rpi3-64": "raspberry_pi", "rpi4": "raspberry_pi", "rpi4-64": "raspberry_pi", + "rpi5-64": "raspberry_pi", "yellow": "homeassistant_yellow", } diff --git a/homeassistant/components/hassio/addon_manager.py b/homeassistant/components/hassio/addon_manager.py index 22265f49912f03..7f9299fa2b10b7 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -43,7 +43,7 @@ def api_error( """Handle HassioAPIError and raise a specific AddonError.""" def handle_hassio_api_error( - func: _FuncType[_AddonManagerT, _P, _R] + func: _FuncType[_AddonManagerT, _P, _R], ) -> _ReturnFuncType[_AddonManagerT, _P, _R]: """Handle a HassioAPIError.""" diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index e2cd1bae2705ba..f57cfa472c4305 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -17,7 +17,7 @@ from .entity import HassioAddonEntity -@dataclass +@dataclass(frozen=True) class HassioBinarySensorEntityDescription(BinarySensorEntityDescription): """Hassio binary sensor entity description.""" diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 419d80484cf34a..9d72d5842fdc70 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -6,6 +6,7 @@ import logging import os import re +from typing import TYPE_CHECKING from urllib.parse import quote, unquote import aiohttp @@ -156,6 +157,9 @@ async def _handle(self, request: web.Request, path: str) -> web.StreamResponse: # _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 b29f80ff2b3841..0c0fe55b686247 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -17,7 +17,6 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.typing import UNDEFINED from .const import X_HASS_SOURCE, X_INGRESS_PATH @@ -68,18 +67,20 @@ def __init__(self, host: str, websession: aiohttp.ClientSession) -> None: self._websession = websession @lru_cache - def _create_url(self, token: str, path: str) -> str: + def _create_url(self, token: str, path: str) -> URL: """Create URL to service.""" base_path = f"/ingress/{token}/" url = f"http://{self._host}{base_path}{quote(path)}" try: - if not URL(url).path.startswith(base_path): - raise HTTPBadRequest() + target_url = URL(url) except ValueError as err: raise HTTPBadRequest() from err - return url + if not target_url.path.startswith(base_path): + raise HTTPBadRequest() + + return target_url async def _handle( self, request: web.Request, token: str, path: str @@ -129,7 +130,7 @@ async def _handle_websocket( # Support GET query if request.query_string: - url = f"{url}?{request.query_string}" + url = url.with_query(request.query_string) # Start proxy async with self._websession.ws_connect( @@ -172,7 +173,7 @@ async def _handle_request( 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 = (maybe_content_type.partition(";"))[0].strip() + content_type: str = (maybe_content_type.partition(";"))[0].strip() else: content_type = result.content_type # Simple request @@ -188,11 +189,12 @@ async def _handle_request( status=result.status, content_type=content_type, body=body, + zlib_executor_size=32768, ) if content_length_int > MIN_COMPRESSED_SIZE and should_compress( content_type or simple_response.content_type ): - enable_compression(simple_response) + simple_response.enable_compression() await simple_response.prepare(request) return simple_response diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 8337405641c7fb..fcfe23dda6e6c6 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -1,6 +1,7 @@ """Repairs implementation for supervisor integration.""" +from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from types import MethodType from typing import Any @@ -116,7 +117,12 @@ async def _async_step_apply_suggestion( return self.async_create_entry(data={}) @staticmethod - def _async_step(suggestion: Suggestion) -> Callable: + def _async_step( + suggestion: Suggestion, + ) -> Callable[ + [SupervisorIssueRepairFlow, dict[str, str] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler for a suggestion.""" async def _async_step( diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 19621e28d032a5..54ea2f3e5bd6da 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 7bd362cf3d7caa..df18fc7834a750 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 f5b97a7fb13d13..9eab92dce5cbea 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -21,7 +21,7 @@ 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 @@ async def get( 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 523b1fafb7fd84..7e28e69e5f9763 100644 --- a/homeassistant/components/history/helpers.py +++ b/homeassistant/components/history/helpers.py @@ -4,6 +4,8 @@ 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 24ec07b6a87813..4be63f29c02743 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -39,7 +39,7 @@ 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/hive/__init__.py b/homeassistant/components/hive/__init__.py index ba060caa43a278..1a386d3b2719d5 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -131,7 +131,7 @@ async def async_remove_config_entry_device( def refresh_system( - func: Callable[Concatenate[_HiveEntityT, _P], Awaitable[Any]] + func: Callable[Concatenate[_HiveEntityT, _P], Awaitable[Any]], ) -> Callable[Concatenate[_HiveEntityT, _P], Coroutine[Any, Any, None]]: """Force update all entities after state change.""" diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 67da3617b44278..870223f8fe6e6b 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhiveapi==0.5.14"] + "requirements": ["pyhiveapi==0.5.16"] } diff --git a/homeassistant/components/hlk_sw16/strings.json b/homeassistant/components/hlk_sw16/strings.json index d6e3212b4eab64..ba74547e355e15 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/holiday/__init__.py b/homeassistant/components/holiday/__init__.py new file mode 100644 index 00000000000000..224b1b01294052 --- /dev/null +++ b/homeassistant/components/holiday/__init__.py @@ -0,0 +1,20 @@ +"""The Holiday integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Holiday from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py new file mode 100644 index 00000000000000..bb9a332cb736fd --- /dev/null +++ b/homeassistant/components/holiday/calendar.py @@ -0,0 +1,134 @@ +"""Holiday Calendar.""" +from __future__ import annotations + +from datetime import datetime + +from holidays import HolidayBase, country_holidays + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import CONF_PROVINCE, DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Holiday Calendar config entry.""" + country: str = config_entry.data[CONF_COUNTRY] + province: str | None = config_entry.data.get(CONF_PROVINCE) + language = hass.config.language + + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=language, + ) + if language == "en": + for lang in obj_holidays.supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=lang, + ) + language = lang + break + + async_add_entities( + [ + HolidayCalendarEntity( + config_entry.title, + country, + province, + language, + obj_holidays, + config_entry.entry_id, + ) + ], + True, + ) + + +class HolidayCalendarEntity(CalendarEntity): + """Representation of a Holiday Calendar element.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + name: str, + country: str, + province: str | None, + language: str, + obj_holidays: HolidayBase, + unique_id: str, + ) -> None: + """Initialize HolidayCalendarEntity.""" + self._country = country + self._province = province + self._location = name + self._language = language + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, + name=name, + ) + self._obj_holidays = obj_holidays + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + next_holiday = None + for holiday_date, holiday_name in sorted( + self._obj_holidays.items(), key=lambda x: x[0] + ): + if holiday_date >= dt_util.now().date(): + next_holiday = (holiday_date, holiday_name) + break + + if next_holiday is None: + return None + + return CalendarEvent( + summary=next_holiday[1], + start=next_holiday[0], + end=next_holiday[0], + location=self._location, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + obj_holidays = country_holidays( + self._country, + subdiv=self._province, + years=list({start_date.year, end_date.year}), + language=self._language, + ) + + event_list: list[CalendarEvent] = [] + + for holiday_date, holiday_name in obj_holidays.items(): + if start_date.date() <= holiday_date <= end_date.date(): + event = CalendarEvent( + summary=holiday_name, + start=holiday_date, + end=holiday_date, + location=self._location, + ) + event_list.append(event) + + return event_list diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py new file mode 100644 index 00000000000000..33268de92b6d8c --- /dev/null +++ b/homeassistant/components/holiday/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for Holiday integration.""" +from __future__ import annotations + +from typing import Any + +from babel import Locale, UnknownLocaleError +from holidays import list_supported_countries +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_COUNTRY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + CountrySelector, + CountrySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_PROVINCE, DOMAIN + +SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False) + + +class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Holiday.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self.data = user_input + + selected_country = user_input[CONF_COUNTRY] + + if SUPPORTED_COUNTRIES[selected_country]: + return await self.async_step_province() + + self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) + + try: + locale = Locale(self.hass.config.language.replace("-", "_")) + except UnknownLocaleError: + # Default to (US) English if language not recognized by babel + # Mainly an issue with English flavors such as "en-GB" + locale = Locale("en") + title = locale.territories[selected_country] + return self.async_create_entry(title=title, data=user_input) + + user_schema = vol.Schema( + { + vol.Optional( + CONF_COUNTRY, default=self.hass.config.country + ): CountrySelector( + CountrySelectorConfig( + countries=list(SUPPORTED_COUNTRIES), + ) + ), + } + ) + + return self.async_show_form(step_id="user", data_schema=user_schema) + + async def async_step_province( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the province step.""" + if user_input is not None: + combined_input: dict[str, Any] = {**self.data, **user_input} + + country = combined_input[CONF_COUNTRY] + province = combined_input.get(CONF_PROVINCE) + + self._async_abort_entries_match( + { + CONF_COUNTRY: country, + CONF_PROVINCE: province, + } + ) + + try: + locale = Locale(self.hass.config.language.replace("-", "_")) + except UnknownLocaleError: + # Default to (US) English if language not recognized by babel + # Mainly an issue with English flavors such as "en-GB" + locale = Locale("en") + province_str = f", {province}" if province else "" + name = f"{locale.territories[country]}{province_str}" + + return self.async_create_entry(title=name, data=combined_input) + + province_schema = vol.Schema( + { + vol.Optional(CONF_PROVINCE): SelectSelector( + SelectSelectorConfig( + options=SUPPORTED_COUNTRIES[self.data[CONF_COUNTRY]], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + + return self.async_show_form(step_id="province", data_schema=province_schema) diff --git a/homeassistant/components/holiday/const.py b/homeassistant/components/holiday/const.py new file mode 100644 index 00000000000000..5d2a567a48881d --- /dev/null +++ b/homeassistant/components/holiday/const.py @@ -0,0 +1,6 @@ +"""Constants for the Holiday integration.""" +from typing import Final + +DOMAIN: Final = "holiday" + +CONF_PROVINCE: Final = "province" diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json new file mode 100644 index 00000000000000..c8ef6c88b13dcd --- /dev/null +++ b/homeassistant/components/holiday/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "holiday", + "name": "Holiday", + "codeowners": ["@jrieger", "@gjohansson-ST"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/holiday", + "iot_class": "local_polling", + "requirements": ["holidays==0.39", "babel==2.13.1"] +} diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json new file mode 100644 index 00000000000000..53d403e790e331 --- /dev/null +++ b/homeassistant/components/holiday/strings.json @@ -0,0 +1,20 @@ +{ + "title": "Holiday", + "config": { + "abort": { + "already_configured": "Already configured. Only a single configuration for country/province combination possible." + }, + "step": { + "user": { + "data": { + "country": "Country" + } + }, + "province": { + "data": { + "province": "Province" + } + } + } + } +} diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7ee44089b2800a..8afd3aaf8cea35 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -10,8 +10,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_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/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json index c35650a518398c..13a7102827c4b4 100644 --- a/homeassistant/components/home_plus_control/strings.json +++ b/homeassistant/components/home_plus_control/strings.json @@ -14,8 +14,8 @@ "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]" + "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 16a7ee5009c455..926ab5025f65c8 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -38,6 +38,7 @@ "scene", "script", "switch", + "todo", "vacuum", "water_heater", } diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 4b694d2b97af09..9abfefc996f008 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -29,14 +29,13 @@ State, callback, ) -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - entity_platform, -) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import 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 +124,7 @@ def _ensure_no_intersection(value: dict[str, Any]) -> dict[str, Any]: SERVICE_APPLY = "apply" SERVICE_CREATE = "create" +SERVICE_DELETE = "delete" _LOGGER = logging.getLogger(__name__) @@ -194,7 +194,9 @@ async def reload_config(call: ServiceCall) -> None: 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 @@ -202,7 +204,7 @@ async def reload_config(call: ServiceCall) -> None: await platform.async_reset() # Extract only the config for the Home Assistant platform, ignore the rest. - for p_type, p_config in config_per_platform(conf, SCENE_DOMAIN): + for p_type, p_config in conf_util.config_per_platform(conf, SCENE_DOMAIN): if p_type != HA_DOMAIN: continue @@ -271,6 +273,41 @@ async def create_service(call: ServiceCall) -> None: 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 f14d9f8148ceff..862ac12cefbfd9 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -12,6 +12,14 @@ "title": "The configured currency is no longer in use", "description": "The currency {currency} is no longer in use, please reconfigure the currency configuration." }, + "legacy_templates_false": { + "title": "`legacy_templates` config key is being removed", + "description": "Nothing will change with your templates.\n\nRemove the `legacy_templates` key from the `homeassistant` configuration in your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "legacy_templates_true": { + "title": "The support for legacy templates is being removed", + "description": "Please do the following steps:\n- Adopt your configuration to support template rendering to native python types.\n- Remove the `legacy_templates` key from the `homeassistant` configuration in your configuration.yaml file.\n- Restart Home Assistant to fix this issue." + }, "python_version": { "title": "Support for Python {current_python_version} is being removed", "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." @@ -138,6 +146,36 @@ } }, "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/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index 51686e54c55c4e..84aafb44808d23 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -1,8 +1,8 @@ """Offer Home Assistant core automation rules.""" import voluptuous as vol -from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.const import CONF_EVENT, CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -30,24 +30,17 @@ async def async_attach_trigger( job = HassJob(action, f"homeassistant trigger {trigger_info}") if event == EVENT_SHUTDOWN: - - @callback - def hass_shutdown(event): - """Execute when Home Assistant is shutting down.""" - hass.async_run_hass_job( - job, - { - "trigger": { - **trigger_data, - "platform": "homeassistant", - "event": event, - "description": "Home Assistant stopping", - } - }, - event.context, - ) - - return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_shutdown) + return hass.async_add_shutdown_job( + job, + { + "trigger": { + **trigger_data, + "platform": "homeassistant", + "event": event, + "description": "Home Assistant stopping", + } + }, + ) # Automation are enabled while hass is starting up, fire right away # Check state because a config reload shouldn't trigger it. diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 0920530524de80..cd90c4acf60bde 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -152,7 +152,7 @@ def _has_all_unique_names_and_ports( - bridges: list[dict[str, Any]] + bridges: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Validate that each homekit bridge configured has a unique name.""" names = [bridge[CONF_NAME] for bridge in bridges] diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 9b27653e4cfd02..d371998aaf8bf0 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -124,12 +124,15 @@ def __init__(self, *args: Any) -> None: ), ) + 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/button.py b/homeassistant/components/homekit_controller/button.py index ff61c632be947f..1c16b2c6483d63 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class HomeKitButtonEntityDescription(ButtonEntityDescription): """Describes Homekit button.""" diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index d3e9a0f13a6c21..1548c23a54311f 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final from aiohomekit.model.characteristics import ( ActivationStateValues, @@ -48,6 +48,12 @@ from .connection import HKDevice from .entity import HomeKitEntity +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) # Map of Homekit operation modes to hass modes @@ -134,6 +140,12 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache(("supported_features", "fan_modes")) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -146,7 +158,7 @@ def current_temperature(self) -> float | None: """Return the current temperature.""" return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT) - @property + @cached_property def fan_modes(self) -> list[str] | None: """Return the available fan modes.""" if self.service.has(CharacteristicsTypes.FAN_STATE_TARGET): @@ -165,7 +177,7 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: {CharacteristicsTypes.FAN_STATE_TARGET: int(fan_mode == FAN_AUTO)} ) - @property + @cached_property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" features = ClimateEntityFeature(0) @@ -179,6 +191,12 @@ def supported_features(self) -> ClimateEntityFeature: class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): """Representation of a Homekit climate device.""" + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache(("hvac_modes", "swing_modes")) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return super().get_characteristic_types() + [ @@ -197,7 +215,7 @@ def _get_rotation_speed_range(self) -> tuple[float, float]: rotation_speed.maxValue or 100 ) - @property + @cached_property def fan_modes(self) -> list[str]: """Return the available fan modes.""" return [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH] @@ -388,7 +406,7 @@ def hvac_mode(self) -> HVACMode: value = self.service.value(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) return TARGET_HEATER_COOLER_STATE_HOMEKIT_TO_HASS[value] - @property + @cached_property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" valid_values = clamp_enum_to_char( @@ -410,7 +428,7 @@ def swing_mode(self) -> str: value = self.service.value(CharacteristicsTypes.SWING_MODE) return SWING_MODE_HOMEKIT_TO_HASS[value] - @property + @cached_property def swing_modes(self) -> list[str]: """Return the list of available swing modes. @@ -428,7 +446,7 @@ async def async_set_swing_mode(self, swing_mode: str) -> None: {CharacteristicsTypes.SWING_MODE: SWING_MODE_HASS_TO_HOMEKIT[swing_mode]} ) - @property + @cached_property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" features = super().supported_features @@ -451,6 +469,12 @@ def supported_features(self) -> ClimateEntityFeature: class HomeKitClimateEntity(HomeKitBaseClimateEntity): """Representation of a Homekit climate device.""" + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache(("hvac_modes",)) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return super().get_characteristic_types() + [ @@ -483,7 +507,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if ( (mode == HVACMode.HEAT_COOL) and ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features ) and heat_temp and cool_temp @@ -524,9 +548,8 @@ def target_temperature(self) -> float | None: value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) if (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT, HVACMode.COOL}) or ( (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT_COOL}) - and not ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features - ) + and ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + not in self.supported_features ): return self.service.value(CharacteristicsTypes.TEMPERATURE_TARGET) return None @@ -536,7 +559,7 @@ def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) if (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT_COOL}) and ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features ): return self.service.value( CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD @@ -548,7 +571,7 @@ def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) if (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT_COOL}) and ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features ): return self.service.value( CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD @@ -560,7 +583,7 @@ def min_temp(self) -> float: """Return the minimum target temp.""" value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) if (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT_COOL}) and ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features ): min_temp = self.service[ CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD @@ -582,7 +605,7 @@ def max_temp(self) -> float: """Return the maximum target temp.""" value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) if (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT_COOL}) and ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features ): max_temp = self.service[ CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD @@ -656,7 +679,7 @@ def hvac_mode(self) -> HVACMode: value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) return MODE_HOMEKIT_TO_HASS[value] - @property + @cached_property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" valid_values = clamp_enum_to_char( @@ -665,7 +688,7 @@ def hvac_modes(self) -> list[HVACMode]: ) return [MODE_HOMEKIT_TO_HASS[mode] for mode in valid_values] - @property + @cached_property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" features = super().supported_features diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 088747d39ffc65..08444555aca54d 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -257,6 +257,11 @@ async def async_step_zeroconf( ) updated_ip_port = { "AccessoryIP": discovery_info.host, + "AccessoryIPs": [ + str(ip_addr) + for ip_addr in discovery_info.ip_addresses + if not ip_addr.is_link_local and not ip_addr.is_unspecified + ], "AccessoryPort": discovery_info.port, } # If the device is already paired and known to us we should monitor c# diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index f94e11456276f8..f99563843c7db2 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -1,7 +1,7 @@ """Support for Homekit covers.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes @@ -28,6 +28,12 @@ from .connection import HKDevice from .entity import HomeKitEntity +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + STATE_STOPPED = "stopped" CURRENT_GARAGE_STATE_MAP = { @@ -128,6 +134,12 @@ def extra_state_attributes(self) -> dict[str, Any]: class HomeKitWindowCover(HomeKitEntity, CoverEntity): """Representation of a HomeKit Window or Window Covering.""" + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache(("supported_features",)) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -142,7 +154,7 @@ def get_characteristic_types(self) -> list[str]: CharacteristicsTypes.OBSTRUCTION_DETECTED, ] - @property + @cached_property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" features = ( diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index d1f48a67e7f615..ba0cad8d666bc5 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -1,6 +1,7 @@ """Homekit Controller entities.""" from __future__ import annotations +import contextlib from typing import Any from aiohomekit.model.characteristics import ( @@ -27,6 +28,7 @@ class HomeKitEntity(Entity): pollable_characteristics: list[tuple[int, int]] watchable_characteristics: list[tuple[int, int]] all_characteristics: set[tuple[int, int]] + all_iids: set[int] accessory_info: Service def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: @@ -73,6 +75,16 @@ def _async_config_changed(self) -> None: if not self._async_remove_entity_if_accessory_or_service_disappeared(): self._async_reconfigure() + @callback + def _async_clear_property_cache(self, properties: tuple[str, ...]) -> None: + """Clear the cache of properties.""" + for prop in properties: + # suppress is slower than try-except-pass, but + # we do not expect to have many properties to clear + # or this to be called often. + with contextlib.suppress(AttributeError): + delattr(self, prop) + @callback def _async_reconfigure(self) -> None: """Reconfigure the entity.""" @@ -149,6 +161,7 @@ def async_setup(self) -> None: self.pollable_characteristics = [] self.watchable_characteristics = [] self.all_characteristics = set() + self.all_iids = set() char_types = self.get_characteristic_types() @@ -164,6 +177,7 @@ def async_setup(self) -> None: self.all_characteristics.update(self.pollable_characteristics) self.all_characteristics.update(self.watchable_characteristics) + self.all_iids = {iid for _, iid in self.all_characteristics} def _setup_characteristic(self, char: Characteristic) -> None: """Configure an entity based on a HomeKit characteristics metadata.""" @@ -219,11 +233,11 @@ def name(self) -> str | None: @property def available(self) -> bool: """Return True if entity is available.""" - return self._accessory.available and all( - c.available - for c in self.service.characteristics - if (self._aid, c.iid) in self.all_characteristics - ) + all_iids = self.all_iids + for char in self.service.characteristics: + if char.iid in all_iids and not char.available: + return False + return self._accessory.available @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 550f86ddbe4fc7..d87b6ab3e399c7 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -1,7 +1,7 @@ """Support for Homekit fans.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes @@ -25,6 +25,12 @@ from .connection import HKDevice from .entity import HomeKitEntity +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + # 0 is clockwise, 1 is counter-clockwise. The match to forward and reverse is so that # its consistent with homeassistant.components.homekit. DIRECTION_TO_HK = { @@ -41,6 +47,20 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): # that controls whether the fan is on or off. on_characteristic: str + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache( + ( + "_speed_range", + "_min_speed", + "_max_speed", + "speed_count", + "supported_features", + ) + ) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -55,19 +75,19 @@ def is_on(self) -> bool: """Return true if device is on.""" return self.service.value(self.on_characteristic) == 1 - @property + @cached_property def _speed_range(self) -> tuple[int, int]: """Return the speed range.""" return (self._min_speed, self._max_speed) - @property + @cached_property def _min_speed(self) -> int: """Return the minimum speed.""" return ( round(self.service[CharacteristicsTypes.ROTATION_SPEED].minValue or 0) + 1 ) - @property + @cached_property def _max_speed(self) -> int: """Return the minimum speed.""" return round(self.service[CharacteristicsTypes.ROTATION_SPEED].maxValue or 100) @@ -94,7 +114,7 @@ def oscillating(self) -> bool: oscillating = self.service.value(CharacteristicsTypes.SWING_MODE) return oscillating == 1 - @property + @cached_property def supported_features(self) -> FanEntityFeature: """Flag supported features.""" features = FanEntityFeature(0) @@ -110,7 +130,7 @@ def supported_features(self) -> FanEntityFeature: return features - @property + @cached_property def speed_count(self) -> int: """Speed count for the fan.""" return round( @@ -157,7 +177,7 @@ async def async_turn_on( if ( percentage is not None - and self.supported_features & FanEntityFeature.SET_SPEED + and FanEntityFeature.SET_SPEED in self.supported_features ): characteristics[CharacteristicsTypes.ROTATION_SPEED] = round( percentage_to_ranged_value(self._speed_range, percentage) diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index 57e4e7e73d8e1b..b5e67e7f1a4fe2 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -1,7 +1,7 @@ """Support for HomeKit Controller humidifier.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes @@ -25,6 +25,12 @@ from .connection import HKDevice from .entity import HomeKitEntity +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + HK_MODE_TO_HA = { 0: "off", 1: MODE_AUTO, @@ -39,46 +45,25 @@ } -class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): +class HomeKitBaseHumidifier(HomeKitEntity, HumidifierEntity): """Representation of a HomeKit Controller Humidifier.""" - _attr_device_class = HumidifierDeviceClass.HUMIDIFIER _attr_supported_features = HumidifierEntityFeature.MODES + _attr_available_modes = [MODE_NORMAL, MODE_AUTO] + _humidity_char = CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD + _on_mode_value = 1 - def get_characteristic_types(self) -> list[str]: - """Define the homekit characteristics the entity cares about.""" - return [ - CharacteristicsTypes.ACTIVE, - CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, - CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, - CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, - ] + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache(("max_humidity", "min_humidity")) + super()._async_reconfigure() @property def is_on(self) -> bool: """Return true if device is on.""" return self.service.value(CharacteristicsTypes.ACTIVE) - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the specified valve on.""" - await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: True}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the specified valve off.""" - await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False}) - - @property - def target_humidity(self) -> int | None: - """Return the humidity we try to reach.""" - return self.service.value( - CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD - ) - - @property - def current_humidity(self) -> int | None: - """Return the current humidity.""" - return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) - @property def mode(self) -> str | None: """Return the current mode, e.g., home, auto, baby. @@ -91,23 +76,36 @@ def mode(self) -> str | None: return MODE_AUTO if mode == 1 else MODE_NORMAL @property - def available_modes(self) -> list[str] | None: - """Return a list of available modes. + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) - Requires HumidifierEntityFeature.MODES. - """ - available_modes = [ - MODE_NORMAL, - MODE_AUTO, - ] + @property + def target_humidity(self) -> int | None: + """Return the humidity we try to reach.""" + return self.service.value(self._humidity_char) + + @cached_property + def min_humidity(self) -> int: + """Return the minimum humidity.""" + return int(self.service[self._humidity_char].minValue or DEFAULT_MIN_HUMIDITY) - return available_modes + @cached_property + def max_humidity(self) -> int: + """Return the maximum humidity.""" + return int(self.service[self._humidity_char].maxValue or DEFAULT_MAX_HUMIDITY) async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - await self.async_put_characteristics( - {CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD: humidity} - ) + await self.async_put_characteristics({self._humidity_char: humidity}) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the specified valve on.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the specified valve off.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False}) async def async_set_mode(self, mode: str) -> None: """Set new mode.""" @@ -121,43 +119,11 @@ async def async_set_mode(self, mode: str) -> None: else: await self.async_put_characteristics( { - CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: 1, + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: self._on_mode_value, CharacteristicsTypes.ACTIVE: True, } ) - @property - def min_humidity(self) -> int: - """Return the minimum humidity.""" - return int( - self.service[ - CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD - ].minValue - or DEFAULT_MIN_HUMIDITY - ) - - @property - def max_humidity(self) -> int: - """Return the maximum humidity.""" - return int( - self.service[ - CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD - ].maxValue - or DEFAULT_MAX_HUMIDITY - ) - - -class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): - """Representation of a HomeKit Controller Humidifier.""" - - _attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER - _attr_supported_features = HumidifierEntityFeature.MODES - - def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: - """Initialise the dehumidifier.""" - super().__init__(accessory, devinfo) - self._attr_unique_id = f"{accessory.unique_id}_{self._iid}_{self.device_class}" - def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -165,101 +131,33 @@ def get_characteristic_types(self) -> list[str]: CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, - CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD, ] - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self.service.value(CharacteristicsTypes.ACTIVE) - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the specified valve on.""" - await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: True}) +class HomeKitHumidifier(HomeKitBaseHumidifier): + """Representation of a HomeKit Controller Humidifier.""" - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the specified valve off.""" - await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False}) + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER - @property - def target_humidity(self) -> int | None: - """Return the humidity we try to reach.""" - return self.service.value( - CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD - ) - @property - def current_humidity(self) -> int | None: - """Return the current humidity.""" - return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) - - @property - def mode(self) -> str | None: - """Return the current mode, e.g., home, auto, baby. +class HomeKitDehumidifier(HomeKitBaseHumidifier): + """Representation of a HomeKit Controller Humidifier.""" - Requires HumidifierEntityFeature.MODES. - """ - mode = self.service.value( - CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE - ) - return MODE_AUTO if mode == 1 else MODE_NORMAL + _attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER + _humidity_char = CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD + _on_mode_value = 2 - @property - def available_modes(self) -> list[str] | None: - """Return a list of available modes. + def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: + """Initialise the dehumidifier.""" + super().__init__(accessory, devinfo) + self._attr_unique_id = f"{accessory.unique_id}_{self._iid}_{self.device_class}" - Requires HumidifierEntityFeature.MODES. - """ - available_modes = [ - MODE_NORMAL, - MODE_AUTO, + def get_characteristic_types(self) -> list[str]: + """Define the homekit characteristics the entity cares about.""" + return super().get_characteristic_types() + [ + CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD ] - return available_modes - - async def async_set_humidity(self, humidity: int) -> None: - """Set new target humidity.""" - await self.async_put_characteristics( - {CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD: humidity} - ) - - async def async_set_mode(self, mode: str) -> None: - """Set new mode.""" - if mode == MODE_AUTO: - await self.async_put_characteristics( - { - CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: 0, - CharacteristicsTypes.ACTIVE: True, - } - ) - else: - await self.async_put_characteristics( - { - CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: 2, - CharacteristicsTypes.ACTIVE: True, - } - ) - - @property - def min_humidity(self) -> int: - """Return the minimum humidity.""" - return int( - self.service[ - CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD - ].minValue - or DEFAULT_MIN_HUMIDITY - ) - - @property - def max_humidity(self) -> int: - """Return the maximum humidity.""" - return int( - self.service[ - CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD - ].maxValue - or DEFAULT_MAX_HUMIDITY - ) - @property def old_unique_id(self) -> str: """Return the old ID of this device.""" diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 5bf810a89db819..f1d36c02933108 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -1,7 +1,7 @@ """Support for Homekit lights.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes @@ -22,6 +22,11 @@ from .connection import HKDevice from .entity import HomeKitEntity +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + async def async_setup_entry( hass: HomeAssistant, @@ -50,6 +55,14 @@ def async_add_service(service: Service) -> bool: class HomeKitLight(HomeKitEntity, LightEntity): """Representation of a Homekit light.""" + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache( + ("supported_features", "min_mireds", "max_mireds", "supported_color_modes") + ) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -78,13 +91,13 @@ def hs_color(self) -> tuple[float, float]: self.service.value(CharacteristicsTypes.SATURATION), ) - @property + @cached_property def min_mireds(self) -> int: """Return minimum supported color temperature.""" min_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue return int(min_value) if min_value else super().min_mireds - @property + @cached_property def max_mireds(self) -> int: """Return the maximum color temperature.""" max_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue @@ -113,7 +126,7 @@ def color_mode(self) -> str: return ColorMode.ONOFF - @property + @cached_property def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" color_modes: set[ColorMode] = set() diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 91fd199e17c0b0..4af79a6f811b25 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.9"], + "requirements": ["aiohomekit==3.1.2"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index 09bb57923c66e8..e6eae1c51ca87d 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -19,14 +19,14 @@ from .entity import CharacteristicEntity -@dataclass +@dataclass(frozen=True) class HomeKitSelectEntityDescriptionRequired: """Required fields for HomeKitSelectEntityDescription.""" choices: dict[str, IntEnum] -@dataclass +@dataclass(frozen=True) class HomeKitSelectEntityDescription( SelectEntityDescription, HomeKitSelectEntityDescriptionRequired ): diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 2d30de24650d5b..eb5b99e126da62 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -46,7 +46,7 @@ from .utils import folded_name -@dataclass +@dataclass(frozen=True) class HomeKitSensorEntityDescription(SensorEntityDescription): """Describes Homekit sensor.""" diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index 15a7aca4a5d705..2ae19152b93ac0 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -30,7 +30,7 @@ ATTR_REMAINING_DURATION = "remaining_duration" -@dataclass +@dataclass(frozen=True) class DeclarativeSwitchEntityDescription(SwitchEntityDescription): """Describes Homekit button.""" diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 036f6c077daffc..35b303a62e3f46 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,12 +1,61 @@ """The Homewizard integration.""" from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate old entry. + + The HWE-SKT had no total_power_*_kwh in 2023.11, in 2023.12 it does. + But simultaneously, the total_power_*_t1_kwh was removed for HWE-SKT. + + This migration migrates the old unique_id to the new one, if possible. + + Migration can be removed after 2024.6 + """ + entity_registry = er.async_get(hass) + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: + replacements = { + "total_power_import_t1_kwh": "total_power_import_kwh", + "total_power_export_t1_kwh": "total_power_export_kwh", + } + + for old_id, new_id in replacements.items(): + if entry.unique_id.endswith(old_id): + new_unique_id = entry.unique_id.replace(old_id, new_id) + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + LOGGER.debug( + "Cannot migrate to unique_id '%s', already exists for '%s'", + new_unique_id, + existing_entity_id, + ) + return None + LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + return { + "new_unique_id": new_unique_id, + } + + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Homewizard from a config entry.""" coordinator = Coordinator(hass) @@ -21,6 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise + await _async_migrate_entries(hass, entry) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator # Abort reauth config flow if active diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index b24b49da96598c..bf425fe5c412cb 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -12,13 +12,12 @@ from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from .const import ( CONF_API_ENABLED, - CONF_PATH, CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index ff0655922832e4..f1a1bee256831a 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from datetime import timedelta +import logging from homewizard_energy.models import Data, Device, State, System @@ -11,11 +12,11 @@ DOMAIN = "homewizard" PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +LOGGER = logging.getLogger(__package__) + # Platform config. CONF_API_ENABLED = "api_enabled" CONF_DATA = "data" -CONF_DEVICE = "device" -CONF_PATH = "path" CONF_PRODUCT_NAME = "product_name" CONF_PRODUCT_TYPE = "product_type" CONF_SERIAL = "serial" diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index 3f7fc0649313d4..4c3ae76a3272de 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -8,6 +8,7 @@ from homeassistant.exceptions import HomeAssistantError +from .const import DOMAIN from .entity import HomeWizardEntity _HomeWizardEntityT = TypeVar("_HomeWizardEntityT", bound=HomeWizardEntity) @@ -15,7 +16,7 @@ def homewizard_exception_handler( - func: Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, Any]] + func: Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, None]]: """Decorate HomeWizard Energy calls to handle HomeWizardEnergy exceptions. @@ -30,11 +31,19 @@ async def 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.config_entry.entry_id ) - raise HomeAssistantError from ex + 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 b987fd6f2080c1..949dda2a8aa0d7 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==3.1.0"], + "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 58e0b02a06c25f..ced870d7072a1f 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -6,6 +6,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness from .const import DOMAIN from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -45,7 +46,9 @@ def __init__( @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: """Set a new value.""" - await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) + await self.coordinator.api.state_set( + brightness=value_to_brightness((0, 100), value) + ) await self.coordinator.async_refresh() @property @@ -61,4 +64,4 @@ def native_value(self) -> float | None: or (brightness := self.coordinator.data.state.brightness) is None ): return None - return round(brightness * (100 / 255)) + return brightness_to_value((0, 100), brightness) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 84aa58f2d27f8a..12655dbbc395cf 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -35,21 +35,13 @@ PARALLEL_UPDATES = 1 -@dataclass -class HomeWizardEntityDescriptionMixin: - """Mixin values for HomeWizard entities.""" - - has_fn: Callable[[Data], bool] - value_fn: Callable[[Data], StateType] - - -@dataclass -class HomeWizardSensorEntityDescription( - SensorEntityDescription, HomeWizardEntityDescriptionMixin -): +@dataclass(frozen=True, 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, ...]] = ( @@ -114,7 +106,7 @@ class HomeWizardSensorEntityDescription( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_import_kwh is not None, - value_fn=lambda data: data.total_energy_import_kwh or None, + value_fn=lambda data: data.total_energy_import_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", @@ -122,8 +114,12 @@ class HomeWizardSensorEntityDescription( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t1_kwh is not None, - value_fn=lambda data: data.total_energy_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", @@ -132,7 +128,7 @@ class HomeWizardSensorEntityDescription( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_import_t2_kwh is not None, - value_fn=lambda data: data.total_energy_import_t2_kwh or None, + value_fn=lambda data: data.total_energy_import_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", @@ -141,7 +137,7 @@ class HomeWizardSensorEntityDescription( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_import_t3_kwh is not None, - value_fn=lambda data: data.total_energy_import_t3_kwh or None, + value_fn=lambda data: data.total_energy_import_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", @@ -150,7 +146,7 @@ class HomeWizardSensorEntityDescription( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_import_t4_kwh is not None, - value_fn=lambda data: data.total_energy_import_t4_kwh or None, + value_fn=lambda data: data.total_energy_import_t4_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", @@ -160,7 +156,7 @@ class HomeWizardSensorEntityDescription( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_export_kwh is not None, enabled_fn=lambda data: data.total_energy_export_kwh != 0, - value_fn=lambda data: data.total_energy_export_kwh or None, + value_fn=lambda data: data.total_energy_export_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", @@ -168,9 +164,13 @@ class HomeWizardSensorEntityDescription( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t1_kwh is not 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 or None, + value_fn=lambda data: data.total_energy_export_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", @@ -180,7 +180,7 @@ class HomeWizardSensorEntityDescription( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_export_t2_kwh is not None, enabled_fn=lambda data: data.total_energy_export_t2_kwh != 0, - value_fn=lambda data: data.total_energy_export_t2_kwh or None, + value_fn=lambda data: data.total_energy_export_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", @@ -190,7 +190,7 @@ class HomeWizardSensorEntityDescription( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_export_t3_kwh is not None, enabled_fn=lambda data: data.total_energy_export_t3_kwh != 0, - value_fn=lambda data: data.total_energy_export_t3_kwh or None, + value_fn=lambda data: data.total_energy_export_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", @@ -200,7 +200,7 @@ class HomeWizardSensorEntityDescription( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_energy_export_t4_kwh is not None, enabled_fn=lambda data: data.total_energy_export_t4_kwh != 0, - value_fn=lambda data: data.total_energy_export_t4_kwh or None, + value_fn=lambda data: data.total_energy_export_t4_kwh, ), HomeWizardSensorEntityDescription( key="active_power_w", @@ -399,7 +399,7 @@ class 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", @@ -426,7 +426,7 @@ class 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, ), ) @@ -436,7 +436,6 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( HomeWizardSensorEntity(coordinator, description) for description in SENSORS diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 3bc55b3c8481c9..acdb321d6ffe14 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -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 ed59963aa41074..fea4d7018bf9ed 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -23,25 +23,17 @@ from .helpers import homewizard_exception_handler -@dataclass -class HomeWizardEntityDescriptionMixin: - """Mixin values for HomeWizard entities.""" +@dataclass(frozen=True, kw_only=True) +class HomeWizardSwitchEntityDescription(SwitchEntityDescription): + """Class describing HomeWizard switch entities.""" - create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] 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]] -@dataclass -class HomeWizardSwitchEntityDescription( - SwitchEntityDescription, HomeWizardEntityDescriptionMixin -): - """Class describing HomeWizard switch entities.""" - - icon_off: str | None = None - - SWITCHES = [ HomeWizardSwitchEntityDescription( key="power_on", diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index e9af4b2fd95aab..2f06dd1cfbe369 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -6,7 +6,13 @@ 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 ( @@ -112,23 +118,17 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids: set = set() - for device in devices.values(): - all_device_ids.add(device.deviceid) + all_device_ids = {device.deviceid for device in devices.values()} for device_entry in device_entries: device_id: str | None = None - remove = True for identifier in device_entry.identifiers: - if identifier[0] != DOMAIN: - remove = False - continue - - device_id = identifier[1] - break + if identifier[0] == DOMAIN: + device_id = identifier[1] + break - if remove and (device_id is None or device_id not in all_device_ids): + if device_id is None or device_id not in all_device_ids: # If device_id is None an invalid device entry was found for this config entry. # If the device_id is not in existing device ids it's a stale device entry. # Remove config entry from this device entry in either case. @@ -492,31 +492,39 @@ async def async_turn_aux_heat_off(self) -> None: 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, + AscConnectionError, asyncio.TimeoutError, ): self._retry += 1 - if self._retry > RETRY: - self._attr_available = False + self._attr_available = self._retry <= RETRY + return + + self._attr_available = True + self._retry = 0 - except (ClientConnectionError, asyncio.TimeoutError): + 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 a53eaaab8ce1be..c4ddba493576e9 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.17"] + "requirements": ["AIOSomecomfort==0.0.24"] } diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index 9542648b996818..0841b7df1ccbd8 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -36,7 +36,7 @@ def _get_temperature_sensor_unit(device: Device) -> str: return UnitOfTemperature.FAHRENHEIT -@dataclass +@dataclass(frozen=True) class HoneywellSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -44,7 +44,7 @@ class HoneywellSensorEntityDescriptionMixin: unit_fn: Callable[[Device], Any] -@dataclass +@dataclass(frozen=True) class HoneywellSensorEntityDescription( SensorEntityDescription, HoneywellSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 5a1d182e80cec8..6bb0c154540081 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -16,7 +16,6 @@ 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_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher from aiohttp_zlib_ng import enable_zlib_ng @@ -238,25 +237,6 @@ async def start_server(*_: Any) -> None: 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.""" @@ -541,7 +521,7 @@ async def start(self) -> None: self.app._router.freeze = lambda: None # type: ignore[method-assign] self.runner = web.AppRunner( - self.app, access_log_class=HomeAssistantAccessLogger + self.app, handler_cancellation=True, shutdown_timeout=10 ) await self.runner.setup() diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index fc7b3c03abebb2..618bab91f7fcc0 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -21,6 +21,7 @@ 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 89d927ee8af5e2..62569495ba70f7 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -84,7 +84,7 @@ async def ban_middleware( def log_invalid_auth( - func: Callable[Concatenate[_HassViewT, Request, _P], Awaitable[Response]] + func: Callable[Concatenate[_HassViewT, Request, _P], Awaitable[Response]], ) -> Callable[Concatenate[_HassViewT, Request, _P], Coroutine[Any, Any, Response]]: """Decorate function to handle invalid auth or failed login attempts.""" @@ -243,5 +243,6 @@ def _add_ban(self, ip_ban: IpBan) -> None: async def async_add_ban(self, remote_addr: IPv4Address | IPv6Address) -> None: """Add a new IP address to the banned list.""" - new_ban = self.ip_bans_lookup[remote_addr] = IpBan(remote_addr) - await self.hass.async_add_executor_job(self._add_ban, new_ban) + if remote_addr not in self.ip_bans_lookup: + new_ban = self.ip_bans_lookup[remote_addr] = IpBan(remote_addr) + await self.hass.async_add_executor_job(self._add_ban, new_ban) diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py index ce5b1b18c06411..4d8ac5c2df5367 100644 --- a/homeassistant/components/http/decorators.py +++ b/homeassistant/components/http/decorators.py @@ -45,7 +45,7 @@ def require_admin( """Home Assistant API decorator to require user to be an admin.""" def decorator_require_admin( - func: _FuncType[_HomeAssistantViewT, _P] + func: _FuncType[_HomeAssistantViewT, _P], ) -> _FuncType[_HomeAssistantViewT, _P]: """Wrap the provided with_admin function.""" diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index f2f8b51665aeb5..399cbf70ad7ffe 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "aiohttp_cors==0.7.0", - "aiohttp-fast-url-dispatcher==0.1.0", - "aiohttp-zlib-ng==0.1.1" + "aiohttp-fast-url-dispatcher==0.3.0", + "aiohttp-zlib-ng==0.1.3" ] } diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 1ab4ef5bd6f662..7fe359d6486cb8 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,7 +1,7 @@ """Static file handling for HTTP component.""" from __future__ import annotations -from collections.abc import Mapping, MutableMapping +from collections.abc import Mapping import mimetypes from pathlib import Path from typing import Final @@ -10,7 +10,7 @@ from aiohttp.web import FileResponse, Request, StreamResponse from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound from aiohttp.web_urldispatcher import StaticResource -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from homeassistant.core import HomeAssistant @@ -19,9 +19,7 @@ CACHE_TIME: Final = 31 * 86400 # = 1 month 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) +PATH_CACHE: LRU[tuple[str, Path, bool], tuple[Path | None, str | None]] = LRU(512) def _get_file_path(rel_url: str, directory: Path, follow_symlinks: bool) -> Path | None: diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 7481381bbc8034..1be3d761a3b215 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -20,7 +20,6 @@ from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant, is_callback -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.json import ( find_paths_unserializable_data, json_bytes, @@ -72,8 +71,9 @@ def json( content_type=CONTENT_TYPE_JSON, status=int(status_code), headers=headers, + zlib_executor_size=32768, ) - enable_compression(response) + response.enable_compression() return response def json_message( diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 6d03b874a64f42..5c0931b97cabc4 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -29,7 +29,6 @@ def __init__( host: None | str | list[str], port: int, *, - shutdown_timeout: float = 10.0, ssl_context: SSLContext | None = None, backlog: int = 128, reuse_address: bool | None = None, @@ -38,7 +37,6 @@ def __init__( """Initialize HomeAssistantTCPSite.""" super().__init__( runner, - shutdown_timeout=shutdown_timeout, ssl_context=ssl_context, backlog=backlog, ) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 929ca0193af670..42a1e066ac7762 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -35,6 +35,7 @@ CONF_RECIPIENT, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, Platform, ) @@ -50,6 +51,7 @@ 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 @@ 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 @@ 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,9 +131,11 @@ PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH, + Platform.SELECT, ] @@ -302,10 +308,11 @@ def logout(self) -> None: """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 +338,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 +534,38 @@ def service_handler(service: ServiceCall) -> None: 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 @@ -580,17 +616,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -@dataclass class HuaweiLteBaseEntity(Entity): """Huawei LTE entity base class.""" - router: Router - - _available: bool = field(default=True, init=False) - _unsub_handlers: list[Callable] = field(default_factory=list, init=False) - _attr_has_entity_name: bool = field(default=True, init=False) + _available = True + _attr_has_entity_name = True _attr_should_poll = False + def __init__(self, router: Router) -> None: + """Initialize.""" + self.router = router + self._unsub_handlers: list[Callable] = [] + @property def _device_unique_id(self) -> str: """Return unique ID for entity within a router.""" diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index bf63422ae3addb..7f709b02dc22ab 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -1,7 +1,6 @@ """Support for Huawei LTE binary sensors.""" from __future__ import annotations -from dataclasses import dataclass, field import logging from typing import Any @@ -48,15 +47,14 @@ async def async_setup_entry( async_add_entities(entities, True) -@dataclass class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntityWithDevice, BinarySensorEntity): """Huawei LTE binary sensor device base class.""" _attr_entity_registry_enabled_default = False - key: str = field(init=False) - item: str = field(init=False) - _raw_state: str | None = field(default=None, init=False) + key: str + item: str + _raw_state: str | None = None @property def _device_unique_id(self) -> str: @@ -100,17 +98,14 @@ async def async_update(self) -> None: } -@dataclass class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE mobile connection binary sensor.""" - _attr_translation_key: str = field(default="mobile_connection", init=False) + _attr_translation_key = "mobile_connection" _attr_entity_registry_enabled_default = True - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_MONITORING_STATUS - self.item = "ConnectionStatus" + key = KEY_MONITORING_STATUS + item = "ConnectionStatus" @property def is_on(self) -> bool: @@ -165,52 +160,40 @@ def icon(self) -> str: return "mdi:wifi" if self.is_on else "mdi:wifi-off" -@dataclass class HuaweiLteWifiStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE WiFi status binary sensor.""" - _attr_translation_key: str = field(default="wifi_status", init=False) + _attr_translation_key: str = "wifi_status" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_MONITORING_STATUS - self.item = "WifiStatus" + key = KEY_MONITORING_STATUS + item = "WifiStatus" -@dataclass class HuaweiLteWifi24ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE 2.4GHz WiFi status binary sensor.""" - _attr_translation_key: str = field(default="24ghz_wifi_status", init=False) + _attr_translation_key: str = "24ghz_wifi_status" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_WLAN_WIFI_FEATURE_SWITCH - self.item = "wifi24g_switch_enable" + key = KEY_WLAN_WIFI_FEATURE_SWITCH + item = "wifi24g_switch_enable" -@dataclass class HuaweiLteWifi5ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE 5GHz WiFi status binary sensor.""" - _attr_translation_key: str = field(default="5ghz_wifi_status", init=False) + _attr_translation_key: str = "5ghz_wifi_status" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_WLAN_WIFI_FEATURE_SWITCH - self.item = "wifi5g_enabled" + key = KEY_WLAN_WIFI_FEATURE_SWITCH + item = "wifi5g_enabled" -@dataclass class HuaweiLteSmsStorageFullBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE SMS storage full binary sensor.""" - _attr_translation_key: str = field(default="sms_storage_full", init=False) + _attr_translation_key: str = "sms_storage_full" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_MONITORING_CHECK_NOTIFICATIONS - self.item = "SmsStorageFull" + key = KEY_MONITORING_CHECK_NOTIFICATIONS + item = "SmsStorageFull" @property def is_on(self) -> bool: diff --git a/homeassistant/components/huawei_lte/button.py b/homeassistant/components/huawei_lte/button.py new file mode 100644 index 00000000000000..f494836e80db0c --- /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 6d7b0b9bb1158f..c97c8d6367bedb 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -16,7 +16,7 @@ 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 @@ 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 @@ 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 @@ async def _async_show_user_form( 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 @@ async def _connect( 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 @@ def _get_connection() -> Connection: 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 @@ def _get_connection() -> Connection: 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 53cc0efb9194b3..eba0f3ce90bc22 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -79,3 +79,6 @@ | 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/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 665c96e4888410..fd1b9850054d1c 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -1,7 +1,6 @@ """Support for device tracking of Huawei LTE routers.""" from __future__ import annotations -from dataclasses import dataclass, field import logging import re from typing import Any, cast @@ -173,16 +172,18 @@ def _better_snakecase(text: str) -> str: return cast(str, snakecase(text)) -@dataclass class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): """Huawei LTE router scanner entity.""" - _mac_address: str + _ip_address: str | None = None + _is_connected: bool = False + _hostname: str | None = None - _ip_address: str | None = field(default=None, init=False) - _is_connected: bool = field(default=False, init=False) - _hostname: str | None = field(default=None, init=False) - _extra_state_attributes: dict[str, Any] = field(default_factory=dict, init=False) + def __init__(self, router: Router, mac_address: str) -> None: + """Initialize.""" + super().__init__(router) + self._extra_state_attributes: dict[str, Any] = {} + self._mac_address = mac_address @property def name(self) -> str: diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index d563bed4d46f7c..9a44024111c857 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/notify.py b/homeassistant/components/huawei_lte/notify.py index 4474188ea22db8..3b72e2216a6074 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -1,7 +1,6 @@ """Support for Huawei LTE router notifications.""" from __future__ import annotations -from dataclasses import dataclass import logging import time from typing import Any @@ -34,12 +33,13 @@ async def async_get_service( return HuaweiLteSmsNotificationService(router, default_targets) -@dataclass class HuaweiLteSmsNotificationService(BaseNotificationService): """Huawei LTE router SMS notification service.""" - router: Router - default_targets: list[str] + def __init__(self, router: Router, default_targets: list[str]) -> None: + """Initialize.""" + self.router = router + self.default_targets = default_targets def send_message(self, message: str = "", **kwargs: Any) -> None: """Send message to target numbers.""" diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py new file mode 100644 index 00000000000000..f211da3c2e8c50 --- /dev/null +++ b/homeassistant/components/huawei_lte/select.py @@ -0,0 +1,139 @@ +"""Support for Huawei LTE selects.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial +import logging + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED + +from . import HuaweiLteBaseEntityWithDevice, Router +from .const import DOMAIN, KEY_NET_NET_MODE + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class HuaweiSelectEntityMixin: + """Mixin for Huawei LTE select entities, to ensure required fields are set.""" + + setter_fn: Callable[[str], None] + + +@dataclass(frozen=True) +class HuaweiSelectEntityDescription(SelectEntityDescription, HuaweiSelectEntityMixin): + """Class describing Huawei LTE select entities.""" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.entry_id] + selects: list[Entity] = [] + + desc = HuaweiSelectEntityDescription( + key=KEY_NET_NET_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:transmission-tower", + name="Preferred network mode", + translation_key="preferred_network_mode", + options=[ + NetworkModeEnum.MODE_AUTO.value, + NetworkModeEnum.MODE_4G_3G_AUTO.value, + NetworkModeEnum.MODE_4G_2G_AUTO.value, + NetworkModeEnum.MODE_4G_ONLY.value, + NetworkModeEnum.MODE_3G_2G_AUTO.value, + NetworkModeEnum.MODE_3G_ONLY.value, + NetworkModeEnum.MODE_2G_ONLY.value, + ], + setter_fn=partial( + router.client.net.set_net_mode, + LTEBandEnum.ALL, + NetworkBandEnum.ALL, + ), + ) + selects.append( + HuaweiLteSelectEntity( + router, + entity_description=desc, + key=desc.key, + item="NetworkMode", + ) + ) + + async_add_entities(selects, True) + + +class HuaweiLteSelectEntity(HuaweiLteBaseEntityWithDevice, SelectEntity): + """Huawei LTE select entity.""" + + entity_description: HuaweiSelectEntityDescription + _raw_state: str | None = None + + def __init__( + self, + router: Router, + entity_description: HuaweiSelectEntityDescription, + key: str, + item: str, + ) -> None: + """Initialize.""" + super().__init__(router) + self.entity_description = entity_description + self.key = key + self.item = item + + name = None + if self.entity_description.name != UNDEFINED: + name = self.entity_description.name + self._attr_name = name or self.item + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self.entity_description.setter_fn(option) + + @property + def current_option(self) -> str | None: + """Return current option.""" + return self._raw_state + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + async def async_added_to_hass(self) -> None: + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].append(f"{SELECT_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(f"{SELECT_DOMAIN}/{self.item}") + + async def async_update(self) -> None: + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + self._raw_state = str(value) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 07486297b32a59..d7fb55659695c5 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -3,13 +3,11 @@ from bisect import bisect from collections.abc import Callable, Sequence -from dataclasses import dataclass, field +from dataclasses import dataclass 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, @@ -31,7 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import HuaweiLteBaseEntityWithDevice +from . import HuaweiLteBaseEntityWithDevice, Router from .const import ( DOMAIN, KEY_DEVICE_INFORMATION, @@ -113,7 +111,7 @@ class HuaweiSensorGroup: exclude: re.Pattern[str] | None = None -@dataclass +@dataclass(frozen=True) class HuaweiSensorEntityDescription(SensorEntityDescription): """Class describing Huawei LTE sensor entities.""" @@ -575,10 +573,6 @@ class HuaweiSensorEntityDescription(SensorEntityDescription): "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 @@ class HuaweiSensorEntityDescription(SensorEntityDescription): 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, ), }, @@ -706,21 +688,26 @@ async def async_setup_entry( async_add_entities(sensors, True) -@dataclass class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): """Huawei LTE sensor entity.""" - key: str - item: str entity_description: HuaweiSensorEntityDescription - - _state: StateType = field(default=None, init=False) - _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 + _state: StateType = None + _unit: str | None = None + _last_reset: datetime | None = None + + def __init__( + self, + router: Router, + key: str, + item: str, + entity_description: HuaweiSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(router) + self.key = key + self.item = item + self.entity_description = entity_description async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index f188eb9e17b55f..225146799a3e94 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)" @@ -270,6 +286,20 @@ "name": "SMS messages (SIM)" } }, + "select": { + "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" + } + } + }, "switch": { "mobile_data": { "name": "Mobile data" @@ -279,6 +309,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/switch.py b/homeassistant/components/huawei_lte/switch.py index eb9370a946fd25..651099be42d6bb 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -1,7 +1,6 @@ """Support for Huawei LTE switches.""" from __future__ import annotations -from dataclasses import dataclass, field import logging from typing import Any @@ -43,17 +42,14 @@ async def async_setup_entry( async_add_entities(switches, True) -@dataclass class HuaweiLteBaseSwitch(HuaweiLteBaseEntityWithDevice, SwitchEntity): """Huawei LTE switch device base class.""" - key: str = field(init=False) - item: str = field(init=False) + key: str + item: str - _attr_device_class: SwitchDeviceClass = field( - default=SwitchDeviceClass.SWITCH, init=False - ) - _raw_state: str | None = field(default=None, init=False) + _attr_device_class: SwitchDeviceClass = SwitchDeviceClass.SWITCH + _raw_state: str | None = None def _turn(self, state: bool) -> None: raise NotImplementedError @@ -88,16 +84,13 @@ async def async_update(self) -> None: self._raw_state = str(value) -@dataclass class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): """Huawei LTE mobile data switch device.""" - _attr_translation_key: str = field(default="mobile_data", init=False) + _attr_translation_key: str = "mobile_data" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_DIALUP_MOBILE_DATASWITCH - self.item = "dataswitch" + key = KEY_DIALUP_MOBILE_DATASWITCH + item = "dataswitch" @property def _device_unique_id(self) -> str: @@ -120,16 +113,13 @@ def icon(self) -> str: return "mdi:signal" if self.is_on else "mdi:signal-off" -@dataclass class HuaweiLteWifiGuestNetworkSwitch(HuaweiLteBaseSwitch): """Huawei LTE WiFi guest network switch device.""" - _attr_translation_key: str = field(default="wifi_guest_network", init=False) + _attr_translation_key: str = "wifi_guest_network" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH - self.item = "WifiEnable" + key = KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH + item = "WifiEnable" @property def _device_unique_id(self) -> str: diff --git a/homeassistant/components/huawei_lte/utils.py b/homeassistant/components/huawei_lte/utils.py index 172e8658928590..df212a1c25d11c 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/bridge.py b/homeassistant/components/hue/bridge.py index 04bd63e5b1f995..c5ceebec3f83e4 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -13,11 +13,11 @@ from homeassistant import core from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST, Platform from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import aiohttp_client -from .const import CONF_API_VERSION, DOMAIN +from .const import DOMAIN from .v1.sensor_base import SensorManager from .v2.device import async_setup_devices from .v2.hue_event import async_setup_hue_events diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 0957329abb0b83..7262dea39ef750 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -14,7 +14,7 @@ from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import ( @@ -26,7 +26,6 @@ from .const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, - CONF_API_VERSION, CONF_IGNORE_AVAILABILITY, DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_UNREACHABLE, diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index 38c2587bc1ab6d..5033aaa427abbf 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -7,7 +7,6 @@ DOMAIN = "hue" -CONF_API_VERSION = "api_version" CONF_IGNORE_AVAILABILITY = "ignore_availability" CONF_SUBTYPE = "subtype" diff --git a/homeassistant/components/hue/migration.py b/homeassistant/components/hue/migration.py index 035da145cc0f72..f4bf6366d61a9d 100644 --- a/homeassistant/components/hue/migration.py +++ b/homeassistant/components/hue/migration.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST, CONF_USERNAME from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import ( async_entries_for_config_entry as devices_for_config_entries, @@ -23,7 +23,7 @@ async_get as async_get_entity_registry, ) -from .const import CONF_API_VERSION, DOMAIN +from .const import DOMAIN LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 4022c61bc36e3a..114f501d7a37ae 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/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index b82b2b34a4bcdf..82cf51d3b26206 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -48,7 +48,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class HuisbaasjeSensorEntityDescription(SensorEntityDescription): """Class describing Airly sensor entities.""" diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 47745c53394dc4..184c638e8f5f23 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -1,11 +1,11 @@ """Provides functionality to interact with humidifier devices.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum +from functools import partial import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -23,12 +23,19 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from .const import ( # noqa: F401 + _DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER, + _DEPRECATED_DEVICE_CLASS_HUMIDIFIER, + _DEPRECATED_SUPPORT_MODES, ATTR_ACTION, ATTR_AVAILABLE_MODES, ATTR_CURRENT_HUMIDITY, @@ -37,19 +44,22 @@ ATTR_MIN_HUMIDITY, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, - DEVICE_CLASS_DEHUMIDIFIER, - DEVICE_CLASS_HUMIDIFIER, DOMAIN, MODE_AUTO, MODE_AWAY, MODE_NORMAL, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, - SUPPORT_MODES, HumidifierAction, HumidifierEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) @@ -71,11 +81,17 @@ class HumidifierDeviceClass(StrEnum): # use the HumidifierDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in HumidifierDeviceClass] +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + # mypy: disallow-any-generics @bind_hass -def is_on(hass, entity_id): +def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the humidifier is on based on the statemachine. Async friendly. @@ -124,14 +140,26 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class HumidifierEntityDescription(ToggleEntityDescription): +class HumidifierEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes humidifier entities.""" device_class: HumidifierDeviceClass | None = None -class HumidifierEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "action", + "current_humidity", + "target_humidity", + "mode", + "available_modes", + "min_humidity", + "max_humidity", + "supported_features", +} + + +class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for humidifier entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -157,12 +185,12 @@ def capability_attributes(self) -> dict[str, Any]: ATTR_MAX_HUMIDITY: self.max_humidity, } - if self.supported_features & HumidifierEntityFeature.MODES: + if HumidifierEntityFeature.MODES in self.supported_features_compat: data[ATTR_AVAILABLE_MODES] = self.available_modes return data - @property + @cached_property def device_class(self) -> HumidifierDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -186,27 +214,27 @@ def state_attributes(self) -> dict[str, Any]: if self.target_humidity is not None: data[ATTR_HUMIDITY] = self.target_humidity - if self.supported_features & HumidifierEntityFeature.MODES: + if HumidifierEntityFeature.MODES in self.supported_features_compat: data[ATTR_MODE] = self.mode return data - @property + @cached_property def action(self) -> HumidifierAction | None: """Return the current action.""" return self._attr_action - @property + @cached_property def current_humidity(self) -> int | None: """Return the current humidity.""" return self._attr_current_humidity - @property + @cached_property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" return self._attr_target_humidity - @property + @cached_property def mode(self) -> str | None: """Return the current mode, e.g., home, auto, baby. @@ -214,7 +242,7 @@ def mode(self) -> str | None: """ return self._attr_mode - @property + @cached_property def available_modes(self) -> list[str] | None: """Return a list of available modes. @@ -238,17 +266,30 @@ async def async_set_mode(self, mode: str) -> None: """Set new mode.""" await self.hass.async_add_executor_job(self.set_mode, mode) - @property + @cached_property def min_humidity(self) -> int: """Return the minimum humidity.""" return self._attr_min_humidity - @property + @cached_property def max_humidity(self) -> int: """Return the maximum humidity.""" return self._attr_max_humidity - @property + @cached_property def supported_features(self) -> HumidifierEntityFeature: """Return the list of supported features.""" return self._attr_supported_features + + @property + def supported_features_compat(self) -> HumidifierEntityFeature: + """Return the supported features as HumidifierEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = HumidifierEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index 09c0714cbebc2a..a1a219ddce7649 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -1,5 +1,13 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial + +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) MODE_NORMAL = "normal" MODE_ECO = "eco" @@ -35,8 +43,12 @@ class HumidifierAction(StrEnum): # DEVICE_CLASS_* below are deprecated as of 2021.12 # use the HumidifierDeviceClass enum instead. -DEVICE_CLASS_HUMIDIFIER = "humidifier" -DEVICE_CLASS_DEHUMIDIFIER = "dehumidifier" +_DEPRECATED_DEVICE_CLASS_HUMIDIFIER = DeprecatedConstant( + "humidifier", "HumidifierDeviceClass.HUMIDIFIER", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER = DeprecatedConstant( + "dehumidifier", "HumidifierDeviceClass.DEHUMIDIFIER", "2025.1" +) SERVICE_SET_MODE = "set_mode" SERVICE_SET_HUMIDITY = "set_humidity" @@ -50,4 +62,10 @@ class HumidifierEntityFeature(IntFlag): # The SUPPORT_MODES constant is deprecated as of Home Assistant 2022.5. # Please use the HumidifierEntityFeature enum instead. -SUPPORT_MODES = 1 +_DEPRECATED_SUPPORT_MODES = DeprecatedConstantEnum( + HumidifierEntityFeature.MODES, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/humidifier/reproduce_state.py b/homeassistant/components/humidifier/reproduce_state.py index b0e9a29caccfcd..be4f1afbeb9d70 100644 --- a/homeassistant/components/humidifier/reproduce_state.py +++ b/homeassistant/components/humidifier/reproduce_state.py @@ -32,10 +32,9 @@ async def _async_reproduce_states( _LOGGER.warning("Unable to find entity %s", state.entity_id) return - async def call_service(service: str, keys: Iterable, data=None): + async def call_service(service: str, keys: Iterable[str]) -> None: """Call service with set of attributes given.""" - data = data or {} - data["entity_id"] = state.entity_id + data = {"entity_id": state.entity_id} for key in keys: if key in state.attributes: data[key] = state.attributes[key] diff --git a/homeassistant/components/humidifier/significant_change.py b/homeassistant/components/humidifier/significant_change.py new file mode 100644 index 00000000000000..cc279a9fa4108a --- /dev/null +++ b/homeassistant/components/humidifier/significant_change.py @@ -0,0 +1,61 @@ +"""Helper to test significant Humidifier state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_ACTION, + ATTR_CURRENT_HUMIDITY, + ATTR_HUMIDITY, + ATTR_MODE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name in [ATTR_ACTION, ATTR_MODE]: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index 2e0bc1c413a8b3..cb6bc72954f374 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -23,14 +23,14 @@ from .model import PowerviewDeviceInfo, PowerviewEntryData -@dataclass +@dataclass(frozen=True) class PowerviewButtonDescriptionMixin: """Mixin to describe a Button entity.""" press_action: Callable[[BaseShade], Any] -@dataclass +@dataclass(frozen=True) class PowerviewButtonDescription( ButtonEntityDescription, PowerviewButtonDescriptionMixin ): diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 37d1193e0e5019..65fe61851dfb87 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -27,7 +27,7 @@ from .model import PowerviewDeviceInfo, PowerviewEntryData -@dataclass +@dataclass(frozen=True) class PowerviewSelectDescriptionMixin: """Mixin to describe a select entity.""" @@ -35,7 +35,7 @@ class PowerviewSelectDescriptionMixin: select_fn: Callable[[BaseShade, str], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class PowerviewSelectDescription( SelectEntityDescription, PowerviewSelectDescriptionMixin ): @@ -116,5 +116,6 @@ def current_option(self) -> str | None: 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/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 330e5dddfa5817..8e16d53ae09c91 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -33,7 +33,7 @@ from .model import PowerviewDeviceInfo, PowerviewEntryData -@dataclass +@dataclass(frozen=True) class PowerviewSensorDescriptionMixin: """Mixin to describe a Sensor entity.""" @@ -42,7 +42,7 @@ class PowerviewSensorDescriptionMixin: create_sensor_fn: Callable[[BaseShade], bool] -@dataclass +@dataclass(frozen=True) class PowerviewSensorDescription( SensorEntityDescription, PowerviewSensorDescriptionMixin ): diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 0ec08e9c791206..8337921acf6326 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -125,58 +125,42 @@ 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 - @property - def is_on(self): - """Return entity state.""" - return self.coordinator.data[self.idx]["state"] - - @property - def available(self) -> bool: - """Return if entity is available.""" - return ( - self.coordinator.last_update_success - and self.coordinator.data[self.idx]["available"] - ) - - @property - def device_info(self): - """Return the device info for this sensor.""" - return DeviceInfo( + self._attr_name = coordinator.data[idx]["name"] + self._attr_unique_id = idx + self._attr_device_info = 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"], + config_entry.entry_id, + config_entry.data[CONF_STATION]["id"], + config_entry.data[CONF_STATION]["type"], ) }, manufacturer=MANUFACTURER, - name=f"Departures at {self.config_entry.data[CONF_STATION]['name']}", + name=f"Departures at {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 + def is_on(self): + """Return entity state.""" + return self.coordinator.data[self.idx]["state"] @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return BinarySensorDeviceClass.PROBLEM + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.coordinator.last_update_success + and self.coordinator.data[self.idx]["available"] + ) @property def extra_state_attributes(self) -> dict[str, Any] | None: diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index 76a7966a6edef5..b30a9b375b0600 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -30,6 +30,8 @@ ATTR_TYPE = "type" ATTR_DELAY = "delay" ATTR_NEXT = "next" +ATTR_CANCELLED = "cancelled" +ATTR_EXTRA = "extra" PARALLEL_UPDATES = 0 BERLIN_TIME_ZONE = get_time_zone("Europe/Berlin") @@ -73,6 +75,19 @@ def __init__(self, hass, config_entry, session, hub): 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: @@ -129,6 +144,8 @@ async def async_update(self, **kwargs: Any) -> None: departure = data["departures"][0] line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) self._attr_available = True self._attr_native_value = ( departure_time @@ -144,6 +161,8 @@ async def async_update(self, **kwargs: Any) -> None: ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) @@ -151,6 +170,8 @@ async def async_update(self, **kwargs: Any) -> None: for departure in data["departures"]: line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) departures.append( { ATTR_DEPARTURE: departure_time @@ -162,23 +183,8 @@ async def async_update(self, **kwargs: Any) -> None: ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) 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 ddff1954eb39ac..9f44d47ecf6ec8 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 1953e413672007..0b12fcb3ddb019 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.values(): + 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 @@ def _update_attrs(self) -> None: 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 c4b37fb4a06607..72df86606d79c4 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -5,8 +5,8 @@ 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 @@ async def _create_entry( 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/const.py b/homeassistant/components/hydrawise/const.py index dc53d847b1f804..724b6ee6203fe5 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -9,7 +9,7 @@ CONF_WATERING_TIME = "watering_minutes" DOMAIN = "hydrawise" -DEFAULT_WATERING_TIME = 15 +DEFAULT_WATERING_TIME = timedelta(minutes=15) MANUFACTURER = "Hydrawise" diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 007b15d2403dda..71922928651d6d 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -2,28 +2,46 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta -from pydrawise.legacy import LegacyHydrawise +from pydrawise import HydrawiseBase +from pydrawise.schema import Controller, User, Zone 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]): +@dataclass +class HydrawiseData: + """Container for data fetched from the Hydrawise API.""" + + user: User + controllers: dict[int, Controller] + zones: dict[int, Zone] + + +class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """The Hydrawise Data Update Coordinator.""" + api: HydrawiseBase + 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) -> HydrawiseData: """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") + user = await self.api.get_user() + controllers = {} + zones = {} + for controller in user.controllers: + controllers[controller.id] = controller + for zone in controller.zones: + zones[zone.id] = zone + return HydrawiseData(user=user, controllers=controllers, zones=zones) diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 38fde3226735b8..887de6ba648646 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: @@ -46,5 +48,8 @@ def _update_attrs(self) -> None: @callback def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" + self.controller = self.coordinator.data.controllers[self.controller.id] + if self.zone: + self.zone = self.coordinator.data.zones[self.zone.id] self._update_attrs() super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 054d084eb76ff8..0bfe1dff001e6a 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.11.0"] + "requirements": ["pydrawise==2024.1.0"] } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 369e952c1be44c..f8490ad00e1a15 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.values() + 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 caaefd7aa265ff..8a92a56975aa6b 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 @@ 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, @@ -49,9 +52,9 @@ vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCH_KEYS): vol.All( cv.ensure_list, [vol.In(SWITCH_KEYS)] ), - vol.Optional(CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME): vol.All( - vol.In(ALLOWED_WATERING_TIME) - ), + vol.Optional( + CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME.total_seconds() // 60 + ): vol.All(vol.In(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.values() + 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 - - def turn_on(self, **kwargs: Any) -> None: + zone: Zone + + 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.total_seconds() + ) 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 a2f8838e2ea300..8d7e3751c4cded 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 1ac7a25e6f89b1..cb2c75d74a9d3a 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/iammeter/__init__.py b/homeassistant/components/iammeter/__init__.py index b53cc35197ca4c..46b8aaca3e7244 100644 --- a/homeassistant/components/iammeter/__init__.py +++ b/homeassistant/components/iammeter/__init__.py @@ -1 +1 @@ -"""Support for IamMeter Devices.""" +"""Iammeter integration.""" diff --git a/homeassistant/components/iammeter/const.py b/homeassistant/components/iammeter/const.py new file mode 100644 index 00000000000000..c2d122c9e32388 --- /dev/null +++ b/homeassistant/components/iammeter/const.py @@ -0,0 +1,11 @@ +"""Constants for the Iammeter integration.""" +from __future__ import annotations + +DOMAIN = "iammeter" + +# Default config for iammeter. +DEFAULT_IP = "192.168.2.15" +DEFAULT_NAME = "IamMeter" +DEVICE_3080 = "WEM3080" +DEVICE_3080T = "WEM3080T" +DEVICE_TYPES = [DEVICE_3080, DEVICE_3080T] diff --git a/homeassistant/components/iammeter/manifest.json b/homeassistant/components/iammeter/manifest.json index 191dbdedb98e3e..f1ebecab00d61f 100644 --- a/homeassistant/components/iammeter/manifest.json +++ b/homeassistant/components/iammeter/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/iammeter", "iot_class": "local_polling", "loggers": ["iammeter"], - "requirements": ["iammeter==0.1.7"] + "requirements": ["iammeter==0.2.1"] } diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index ca468200370a34..df3a873b6c19cf 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -2,26 +2,44 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from asyncio import timeout +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta import logging -from iammeter import real_time_api -from iammeter.power_meter import IamMeterError +from iammeter.client import IamMeter import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + PERCENTAGE, + Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import debounce +from homeassistant.helpers import debounce, entity_registry as er, update_coordinator import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEVICE_3080, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -40,6 +58,51 @@ PLATFORM_TIMEOUT = 8 +def _migrate_to_new_unique_id( + hass: HomeAssistant, model: str, serial_number: str +) -> None: + """Migrate old unique ids to new unique ids.""" + ent_reg = er.async_get(hass) + name_list = [ + "Voltage", + "Current", + "Power", + "ImportEnergy", + "ExportGrid", + "Frequency", + "PF", + ] + phase_list = ["A", "B", "C", "NET"] + id_phase_range = 1 if model == DEVICE_3080 else 4 + id_name_range = 5 if model == DEVICE_3080 else 7 + for row in range(0, id_phase_range): + for idx in range(0, id_name_range): + old_unique_id = f"{serial_number}-{row}-{idx}" + new_unique_id = ( + f"{serial_number}_{name_list[idx]}" + if model == DEVICE_3080 + else f"{serial_number}_{name_list[idx]}_{phase_list[row]}" + ) + entity_id = ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, old_unique_id + ) + if entity_id is not None: + try: + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + except ValueError: + _LOGGER.warning( + "Skip migration of id [%s] to [%s] because it already exists", + old_unique_id, + new_unique_id, + ) + else: + _LOGGER.debug( + "Migrating unique_id from [%s] to [%s]", + old_unique_id, + new_unique_id, + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -51,23 +114,24 @@ async def async_setup_platform( config_port = config[CONF_PORT] config_name = config[CONF_NAME] try: - async with asyncio.timeout(PLATFORM_TIMEOUT): - api = await real_time_api(config_host, config_port) - except (IamMeterError, asyncio.TimeoutError) as err: + api = await hass.async_add_executor_job( + IamMeter, config_host, config_port, config_name + ) + except asyncio.TimeoutError as err: _LOGGER.error("Device is not ready") raise PlatformNotReady from err async def async_update_data(): try: - async with asyncio.timeout(PLATFORM_TIMEOUT): - return await api.get_data() - except (IamMeterError, asyncio.TimeoutError) as err: + async with timeout(PLATFORM_TIMEOUT): + return await hass.async_add_executor_job(api.client.get_data) + except asyncio.TimeoutError as err: raise UpdateFailed from err coordinator = DataUpdateCoordinator( hass, _LOGGER, - name=DEFAULT_DEVICE_NAME, + name=config_name, update_method=async_update_data, update_interval=SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( @@ -75,46 +139,334 @@ async def async_update_data(): ), ) await coordinator.async_refresh() - entities = [] - for sensor_name, (row, idx, unit) in api.iammeter.sensor_map().items(): - serial_number = api.iammeter.serial_number - uid = f"{serial_number}-{row}-{idx}" - entities.append(IamMeter(coordinator, uid, sensor_name, unit, config_name)) - async_add_entities(entities) + model = coordinator.data["Model"] + serial_number = coordinator.data["sn"] + _migrate_to_new_unique_id(hass, model, serial_number) + if model == DEVICE_3080: + async_add_entities( + IammeterSensor(coordinator, description) + for description in SENSOR_TYPES_3080 + ) + else: # DEVICE_3080T: + async_add_entities( + IammeterSensor(coordinator, description) + for description in SENSOR_TYPES_3080T + ) + +class IammeterSensor(update_coordinator.CoordinatorEntity, SensorEntity): + """Representation of a Sensor.""" -class IamMeter(CoordinatorEntity, SensorEntity): - """Class for a sensor.""" + entity_description: IammeterSensorEntityDescription + _attr_has_entity_name = True + _attr_name = None - def __init__(self, coordinator, uid, sensor_name, unit, dev_name): - """Initialize an iammeter sensor.""" + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: IammeterSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" super().__init__(coordinator) - self.uid = uid - self.sensor_name = sensor_name - self.unit = unit - self.dev_name = dev_name + self.entity_description = description + self._attr_unique_id = f"{coordinator.data['sn']}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data["sn"])}, + manufacturer="IamMeter", + name=coordinator.name, + ) @property def native_value(self): - """Return the state of the sensor.""" - return self.coordinator.data.data[self.sensor_name] + """Return the native sensor value.""" + raw_attr = self.coordinator.data.get(self.entity_description.key, None) + if self.entity_description.value: + return self.entity_description.value(raw_attr) + return raw_attr - @property - def unique_id(self): - """Return unique id.""" - return self.uid - @property - def name(self): - """Name of this iammeter attribute.""" - return f"{self.dev_name} {self.sensor_name}" +@dataclass(frozen=True) +class IammeterSensorEntityDescription(SensorEntityDescription): + """Describes Iammeter sensor entity.""" - @property - def icon(self): - """Icon for each sensor.""" - return "mdi:flash" + value: Callable[[float | int], float] | Callable[[datetime], datetime] | None = None - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self.unit + +SENSOR_TYPES_3080: tuple[IammeterSensorEntityDescription, ...] = ( + IammeterSensorEntityDescription( + key="Voltage", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) +SENSOR_TYPES_3080T: tuple[IammeterSensorEntityDescription, ...] = ( + IammeterSensorEntityDescription( + key="Voltage_A", + translation_key="voltage_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current_A", + translation_key="current_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_A", + translation_key="power_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_A", + translation_key="import_energy_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid_A", + translation_key="export_grid_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="Frequency_A", + translation_key="frequency_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_A", + translation_key="pf_a", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Voltage_B", + translation_key="voltage_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current_B", + translation_key="current_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_B", + translation_key="power_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_B", + translation_key="import_energy_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid_B", + translation_key="export_grid_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="Frequency_B", + translation_key="frequency_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_B", + translation_key="pf_b", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Voltage_C", + translation_key="voltage_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current_C", + translation_key="current_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_C", + translation_key="power_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_C", + translation_key="import_energy_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid_C", + translation_key="export_grid_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="Frequency_C", + translation_key="frequency_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_C", + translation_key="pf_c", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Voltage_Net", + translation_key="voltage_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_Net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_Net", + translation_key="import_energy_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="ExportGrid_Net", + translation_key="export_grid_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Frequency_Net", + translation_key="frequency_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_Net", + translation_key="pf_net", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), +) diff --git a/homeassistant/components/iammeter/strings.json b/homeassistant/components/iammeter/strings.json new file mode 100644 index 00000000000000..6d0c3797dfc6b7 --- /dev/null +++ b/homeassistant/components/iammeter/strings.json @@ -0,0 +1,69 @@ +{ + "entity": { + "sensor": { + "voltage_a": { + "name": "Voltage A" + }, + "voltage_b": { + "name": "Voltage B" + }, + "voltage_c": { + "name": "Voltage C" + }, + "current_a": { + "name": "Current A" + }, + "current_b": { + "name": "Current B" + }, + "current_c": { + "name": "Current C" + }, + "power_a": { + "name": "Power A" + }, + "power_b": { + "name": "Power B" + }, + "power_c": { + "name": "Power C" + }, + "import_energy_a": { + "name": "ImportEnergy A" + }, + "import_energy_b": { + "name": "ImportEnergy B" + }, + "import_energy_c": { + "name": "ImportEnergy C" + }, + "export_grid_a": { + "name": "ExportGrid A" + }, + "export_grid_b": { + "name": "ExportGrid B" + }, + "export_grid_c": { + "name": "ExportGrid C" + }, + "frequency_a": { + "name": "Frequency A" + }, + "frequency_b": { + "name": "Frequency B" + }, + "frequency_c": { + "name": "Frequency C" + }, + "pf_a": { + "name": "PF A" + }, + "pf_b": { + "name": "PF B" + }, + "pf_c": { + "name": "PF C" + } + } + } +} diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index fceb0d72213751..062548666c4895 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -185,7 +185,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def refresh_system( - func: Callable[Concatenate[_AqualinkEntityT, _P], Awaitable[Any]] + func: Callable[Concatenate[_AqualinkEntityT, _P], Awaitable[Any]], ) -> Callable[Concatenate[_AqualinkEntityT, _P], Coroutine[Any, Any, None]]: """Force update all entities after state change.""" diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py index b3895ce23b402c..3ce145fc3b95cc 100644 --- a/homeassistant/components/ibeacon/sensor.py +++ b/homeassistant/components/ibeacon/sensor.py @@ -23,14 +23,14 @@ from .entity import IBeaconEntity -@dataclass +@dataclass(frozen=True) class IBeaconRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[iBeaconAdvertisement], str | int | None] -@dataclass +@dataclass(frozen=True) class IBeaconSensorEntityDescription(SensorEntityDescription, IBeaconRequiredKeysMixin): """Describes iBeacon sensor entity.""" diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 0a17ebec96cc90..5e112aa39f7b94 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -24,7 +24,7 @@ from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -44,6 +44,7 @@ def __init__( 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 @@ async def async_disconnect(self) -> 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 @@ def async_set_updated_data(self, data: int | None) -> 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 index 6cae9a428957c0..d11738c6bcda75 100644 --- a/homeassistant/components/idasen_desk/button.py +++ b/homeassistant/components/idasen_desk/button.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class IdasenDeskButtonDescriptionMixin: """Mixin to describe a IdasenDesk button entity.""" @@ -30,7 +30,7 @@ class IdasenDeskButtonDescriptionMixin: ] -@dataclass +@dataclass(frozen=True) class IdasenDeskButtonDescription( ButtonEntityDescription, IdasenDeskButtonDescriptionMixin ): diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py index 3148616d182074..1daebe52420247 100644 --- a/homeassistant/components/idasen_desk/cover.py +++ b/homeassistant/components/idasen_desk/cover.py @@ -3,6 +3,8 @@ from typing import Any +from bleak.exc import BleakError + from homeassistant.components.cover import ( ATTR_POSITION, CoverDeviceClass, @@ -12,6 +14,7 @@ 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 @@ def is_closed(self) -> bool: 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 ed941f4f87d263..0a96a976bb3661 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 00000000000000..f4e04ea762b8cc --- /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(frozen=True) +class IdasenDeskSensorDescriptionMixin: + """Required values for IdasenDesk sensors.""" + + value_fn: Callable[[IdasenDeskCoordinator], float | None] + + +@dataclass(frozen=True) +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 6b9bf80edfc16a..446ef93e542e09 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/image/__init__.py b/homeassistant/components/image/__init__.py index e5c40affe0fd22..4c5a9df881010f 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta import logging from random import SystemRandom -from typing import Final, final +from typing import TYPE_CHECKING, Final, final from aiohttp import hdrs, web import httpx @@ -30,6 +30,12 @@ from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401 +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=30) @@ -44,8 +50,7 @@ GET_IMAGE_TIMEOUT: Final = 10 -@dataclass -class ImageEntityDescription(EntityDescription): +class ImageEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes image entities.""" @@ -123,7 +128,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class ImageEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "content_type", + "image_last_updated", + "image_url", +} + + +class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """The base class for image entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -144,7 +156,7 @@ def __init__(self, hass: HomeAssistant, verify_ssl: bool = False) -> None: self.access_tokens: collections.deque = collections.deque([], 2) self.async_update_token() - @property + @cached_property def content_type(self) -> str: """Image content type.""" return self._attr_content_type @@ -156,12 +168,12 @@ def entity_picture(self) -> str | None: return self._attr_entity_picture return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) - @property + @cached_property def image_last_updated(self) -> datetime | None: - """The time when the image was last updated.""" + """Time the image was last updated.""" return self._attr_image_last_updated - @property + @cached_property def image_url(self) -> str | None | UndefinedType: """Return URL of image.""" return self._attr_image_url diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 7640925451ac40..bb356c09367282 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum import logging @@ -25,7 +24,6 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -120,8 +118,7 @@ async def async_scan_service(service: ServiceCall) -> None: return True -@dataclass -class ImageProcessingEntityDescription(EntityDescription): +class ImageProcessingEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes sensor entities.""" device_class: ImageProcessingDeviceClass | None = None @@ -235,9 +232,7 @@ def state_attributes(self) -> dict[str, Any]: def process_faces(self, faces: list[FaceInformation], total: int) -> None: """Send event with detected faces and store data.""" - run_callback_threadsafe( - self.hass.loop, self.async_process_faces, faces, total - ).result() + self.hass.loop.call_soon_threadsafe(self.async_process_faces, faces, total) @callback def async_process_faces(self, faces: list[FaceInformation], total: int) -> None: @@ -267,7 +262,7 @@ def async_process_faces(self, faces: list[FaceInformation], total: int) -> None: continue face.update({ATTR_ENTITY_ID: self.entity_id}) - self.hass.async_add_job(self.hass.bus.async_fire, EVENT_DETECT_FACE, face) + self.hass.bus.async_fire(EVENT_DETECT_FACE, face) # type: ignore[arg-type] # Update entity store self.faces = faces diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 3914e0c52c1a19..fea2583a27a3cb 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 59c24b11e51e1a..5591980b2f1a23 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta import email from email.header import decode_header, make_header +from email.message import Message from email.utils import parseaddr, parsedate_to_datetime import logging from typing import Any @@ -61,7 +62,7 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" ssl_cipher_list: str = data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) if data.get(CONF_VERIFY_SSL, True): - ssl_context = client_context(ssl_cipher_list=ssl_cipher_list) + ssl_context = client_context(ssl_cipher_list=SSLCipherList(ssl_cipher_list)) else: ssl_context = create_no_verify_ssl_context() client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) @@ -96,8 +97,9 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: class ImapMessage: """Class to parse an RFC822 email message.""" - def __init__(self, raw_message: bytes) -> None: + def __init__(self, raw_message: bytes, charset: str = "utf-8") -> None: """Initialize IMAP message.""" + self._charset = charset self.email_message = email.message_from_bytes(raw_message) @property @@ -157,18 +159,30 @@ def text(self) -> str: message_html: str | None = None message_untyped_text: str | None = None + def _decode_payload(part: Message) -> str: + """Try to decode text payloads. + + Common text encodings are quoted-printable or base64. + Falls back to the raw content part if decoding fails. + """ + try: + return str(part.get_payload(decode=True).decode(self._charset)) + except ValueError: + return str(part.get_payload()) + + part: Message for part in self.email_message.walk(): if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: if message_text is None: - message_text = part.get_payload() + message_text = _decode_payload(part) elif part.get_content_type() == "text/html": if message_html is None: - message_html = part.get_payload() + message_html = _decode_payload(part) elif ( part.get_content_type().startswith("text") and message_untyped_text is None ): - message_untyped_text = part.get_payload() + message_untyped_text = str(part.get_payload()) if message_text is not None: return message_text @@ -223,7 +237,9 @@ async def _async_process_event(self, last_message_uid: str) -> None: """Send a event for the last message if the last message was changed.""" response = await self.imap_client.fetch(last_message_uid, "BODY.PEEK[]") if response.result == "OK": - message = ImapMessage(response.lines[1]) + message = ImapMessage( + response.lines[1], charset=self.config_entry.data[CONF_CHARSET] + ) # Set `initial` to `False` if the last message is triggered again initial: bool = True if (message_id := message.message_id) == self._last_message_id: diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index bfc86ac01629f6..762f37ef5d4e67 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -405,7 +405,7 @@ async def _try_call(func: Coroutine[Any, Any, _T]) -> _T: raise AbortFlow("characteristic_missing") from err except improv_ble_errors.CommandFailed: raise - except Exception as err: # pylint: disable=broad-except + except Exception as err: _LOGGER.exception("Unexpected exception") raise AbortFlow("unknown") from err diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 9e8cabbe2536a3..535d8b61653255 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -23,7 +23,7 @@ INCOMFORT_TAP_TEMP = "Tap Temp" -@dataclass +@dataclass(frozen=True) class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" diff --git a/homeassistant/components/indianamichiganpower/__init__.py b/homeassistant/components/indianamichiganpower/__init__.py new file mode 100644 index 00000000000000..06870a50604d4d --- /dev/null +++ b/homeassistant/components/indianamichiganpower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Indiana Michigan Power.""" diff --git a/homeassistant/components/indianamichiganpower/manifest.json b/homeassistant/components/indianamichiganpower/manifest.json new file mode 100644 index 00000000000000..ee6ff0402c7ec9 --- /dev/null +++ b/homeassistant/components/indianamichiganpower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "indianamichiganpower", + "name": "Indiana Michigan Power", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index f879ab37e8fcc6..24c80dc1d54c19 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -22,9 +22,17 @@ from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITY_ID, + CONF_HOST, + CONF_PASSWORD, + CONF_PATH, + CONF_PORT, + CONF_SSL, CONF_TIMEOUT, + CONF_TOKEN, CONF_UNIT_OF_MEASUREMENT, CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_UNAVAILABLE, @@ -56,23 +64,15 @@ CONF_COMPONENT_CONFIG_GLOB, CONF_DB_NAME, CONF_DEFAULT_MEASUREMENT, - CONF_HOST, CONF_IGNORE_ATTRIBUTES, CONF_MEASUREMENT_ATTR, CONF_ORG, CONF_OVERRIDE_MEASUREMENT, - CONF_PASSWORD, - CONF_PATH, - CONF_PORT, CONF_PRECISION, CONF_RETRY_COUNT, - CONF_SSL, CONF_SSL_CA_CERT, CONF_TAGS, CONF_TAGS_ATTRIBUTES, - CONF_TOKEN, - CONF_USERNAME, - CONF_VERIFY_SSL, CONNECTION_ERROR, DEFAULT_API_VERSION, DEFAULT_HOST_V2, diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index f3b0b66df54797..5ffd70fe992a3f 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -33,7 +33,6 @@ CONF_PRECISION = "precision" CONF_SSL_CA_CERT = "ssl_ca_cert" -CONF_LANGUAGE = "language" CONF_QUERIES = "queries" CONF_QUERIES_FLUX = "queries_flux" CONF_GROUP_FUNCTION = "group_function" diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index b4f643e876f18f..a46ec5812074af 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -13,6 +13,7 @@ ) from homeassistant.const import ( CONF_API_VERSION, + CONF_LANGUAGE, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -35,7 +36,6 @@ CONF_FIELD, CONF_GROUP_FUNCTION, CONF_IMPORTS, - CONF_LANGUAGE, CONF_MEASUREMENT_NAME, CONF_QUERIES, CONF_QUERIES_FLUX, diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index f5bafd935a0f74..36e977f6db0841 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -38,6 +38,7 @@ 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 .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_REMOVE_OVERRIDE = "remove_override" STEP_REMOVE_X10 = "remove_x10" MODEM_TYPE = "modem_type" +PLM_MANUAL = "manual" _LOGGER = logging.getLogger(__name__) @@ -129,16 +132,35 @@ async def async_step_plm(self, user_input=None): """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 5fa45a16fb603b..cf2109638416fa 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.3", "insteon-frontend-home-assistant==0.4.0" ], "usb": [ diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index e6b22a8cbb964e..497af743195b46 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/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index b19c592a5cf818..503b97f183d79f 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -21,14 +21,14 @@ from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireBinarySensorRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[IntellifirePollData], bool] -@dataclass +@dataclass(frozen=True) class IntellifireBinarySensorEntityDescription( BinarySensorEntityDescription, IntellifireBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index 3911efeb5b9bf0..7c376eeec4ce45 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -26,7 +26,7 @@ from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireFanRequiredKeysMixin: """Required keys for fan entity.""" @@ -35,7 +35,7 @@ class IntellifireFanRequiredKeysMixin: speed_range: tuple[int, int] -@dataclass +@dataclass(frozen=True) class IntellifireFanEntityDescription( FanEntityDescription, IntellifireFanRequiredKeysMixin ): diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index 05994919296dbc..a807735ed79fa2 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -22,7 +22,7 @@ from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireLightRequiredKeysMixin: """Required keys for fan entity.""" @@ -30,7 +30,7 @@ class IntellifireLightRequiredKeysMixin: value_fn: Callable[[IntellifirePollData], bool] -@dataclass +@dataclass(frozen=True) class IntellifireLightEntityDescription( LightEntityDescription, IntellifireLightRequiredKeysMixin ): diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index bc42b977f12cff..c974378fb71aaf 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -24,14 +24,14 @@ from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireSensorRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[IntellifirePollData], int | str | datetime | None] -@dataclass +@dataclass(frozen=True) class IntellifireSensorEntityDescription( SensorEntityDescription, IntellifireSensorRequiredKeysMixin, diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index 1af4d8c0e91cff..03e3a2be0a2423 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -18,7 +18,7 @@ from .entity import IntellifireEntity -@dataclass() +@dataclass(frozen=True) class IntellifireSwitchRequiredKeysMixin: """Mixin for required keys.""" @@ -27,7 +27,7 @@ class IntellifireSwitchRequiredKeysMixin: value_fn: Callable[[IntellifirePollData], bool] -@dataclass +@dataclass(frozen=True) class IntellifireSwitchEntityDescription( SwitchEntityDescription, IntellifireSwitchRequiredKeysMixin ): diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 2f42edb4bc1d9f..de6091e3638235 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -52,9 +52,9 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> iOSNotificationService | None: """Get the iOS notification service.""" - if "notify.ios" not in hass.config.components: + if "ios.notify" not in hass.config.components: # Need this to enable requirements checking in the app. - hass.config.components.add("notify.ios") + hass.config.components.add("ios.notify") if not ios.devices_with_push(hass): return None diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 27ecc1574e3b60..4faac347c40ab4 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -36,7 +36,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class IotaWattSensorEntityDescription(SensorEntityDescription): """Class describing IotaWatt sensor entities.""" @@ -45,14 +45,14 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { "Amps": IotaWattSensorEntityDescription( - "Amps", + key="Amps", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), "Hz": IotaWattSensorEntityDescription( - "Hz", + key="Hz", native_unit_of_measurement=UnitOfFrequency.HERTZ, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.FREQUENCY, @@ -60,7 +60,7 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), "PF": IotaWattSensorEntityDescription( - "PF", + key="PF", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER_FACTOR, @@ -68,40 +68,40 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), "Watts": IotaWattSensorEntityDescription( - "Watts", + key="Watts", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), "WattHours": IotaWattSensorEntityDescription( - "WattHours", + key="WattHours", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), "VA": IotaWattSensorEntityDescription( - "VA", + key="VA", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.APPARENT_POWER, entity_registry_enabled_default=False, ), "VAR": IotaWattSensorEntityDescription( - "VAR", + key="VAR", native_unit_of_measurement=VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash", entity_registry_enabled_default=False, ), "VARh": IotaWattSensorEntityDescription( - "VARh", + key="VARh", native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash", entity_registry_enabled_default=False, ), "Volts": IotaWattSensorEntityDescription( - "Volts", + key="Volts", native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, @@ -125,7 +125,7 @@ def _create_entity(key: str) -> IotaWattSensor: created.add(key) data = coordinator.data["sensors"][key] description = ENTITY_DESCRIPTION_KEY_MAP.get( - data.getUnit(), IotaWattSensorEntityDescription("base_sensor") + data.getUnit(), IotaWattSensorEntityDescription(key="base_sensor") ) return IotaWattSensor( diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json index f21dfe0cd09eef..266b32c5c31394 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/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index cb0620ceca0d15..99e994069a59ee 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -23,18 +23,13 @@ _LOGGER = logging.getLogger(__name__) -@dataclass -class IPMARequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class IPMASensorEntityDescription(SensorEntityDescription): + """Describes a IPMA sensor entity.""" value_fn: Callable[[Location, IPMA_API], Coroutine[Location, IPMA_API, int | None]] -@dataclass -class IPMASensorEntityDescription(SensorEntityDescription, IPMARequiredKeysMixin): - """Describes IPMA sensor entity.""" - - async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None: """Retrieve RCM.""" fire_risk: RCM = await location.fire_risk(api) diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 3bc7035e26bbf2..d1acbe9bd96211 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -12,6 +12,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LOCATION, PERCENTAGE, EntityCategory @@ -36,14 +37,14 @@ from .entity import IPPEntity -@dataclass +@dataclass(frozen=True) class IPPSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Printer], StateType | datetime] -@dataclass +@dataclass(frozen=True) class IPPSensorEntityDescription( SensorEntityDescription, IPPSensorEntityDescriptionMixin ): @@ -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/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index def58d602012a2..aa5528cc06ab76 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client.disable_request_retries() async def async_get_data_from_api( - api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]] + api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]], ) -> dict[str, Any]: """Get data from a particular API coroutine.""" try: diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 2925ca527bc8fe..86ef3ce271f7ed 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -1,8 +1,10 @@ """The islamic_prayer_times component.""" from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -13,6 +15,8 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" @@ -41,6 +45,34 @@ def update_unique_id( return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + new = {**config_entry.data} + if config_entry.minor_version < 2: + lat = hass.config.latitude + lon = hass.config.longitude + new = { + CONF_LATITUDE: lat, + CONF_LONGITUDE: lon, + } + unique_id = f"{lat}-{lon}" + config_entry.version = 1 + config_entry.minor_version = 2 + hass.config_entries.async_update_entry( + config_entry, data=new, unique_id=unique_id + ) + + _LOGGER.debug("Migration to version %s successful", config_entry.version) + + return True + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Islamic Prayer entry from config_entry.""" if unload_ok := await hass.config_entries.async_unload_platforms( diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 333b6b36c87ba4..2fde06f576d353 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -3,16 +3,22 @@ from typing import Any +from prayer_times_calculator import InvalidResponseError, PrayerTimesCalculator +from requests.exceptions import ConnectionError as ConnError import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( + LocationSelector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, ) +import homeassistant.util.dt as dt_util from .const import ( CALC_METHODS, @@ -32,10 +38,31 @@ ) +async def async_validate_location( + hass: HomeAssistant, lon: float, lat: float +) -> dict[str, str]: + """Check if the selected location is valid.""" + errors = {} + calc = PrayerTimesCalculator( + latitude=lat, + longitude=lon, + calculation_method=DEFAULT_CALC_METHOD, + date=str(dt_util.now().date()), + ) + try: + await hass.async_add_executor_job(calc.fetch_prayer_times) + except InvalidResponseError: + errors["base"] = "invalid_location" + except ConnError: + errors["base"] = "conn_error" + return errors + + class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle the Islamic Prayer config flow.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -49,13 +76,39 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + errors = {} - if user_input is None: - return self.async_show_form(step_id="user") + if user_input is not None: + lat: float = user_input[CONF_LOCATION][CONF_LATITUDE] + lon: float = user_input[CONF_LOCATION][CONF_LONGITUDE] + await self.async_set_unique_id(f"{lat}-{lon}") + self._abort_if_unique_id_configured() + + if not (errors := await async_validate_location(self.hass, lat, lon)): + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_LATITUDE: lat, + CONF_LONGITUDE: lon, + }, + ) - return self.async_create_entry(title=NAME, data=user_input) + home_location = { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional(CONF_NAME, default=NAME): TextSelector(), + vol.Required( + CONF_LOCATION, default=home_location + ): LocationSelector(), + } + ), + errors=errors, + ) class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow): diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 161ce7b26448b0..be138e7b45bb85 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -9,6 +9,7 @@ from requests.exceptions import ConnectionError as ConnError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_call_later, async_track_point_in_time from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -36,12 +37,14 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim def __init__(self, hass: HomeAssistant) -> None: """Initialize the Islamic Prayer client.""" - self.event_unsub: CALLBACK_TYPE | None = None super().__init__( hass, _LOGGER, name=DOMAIN, ) + self.latitude = self.config_entry.data[CONF_LATITUDE] + self.longitude = self.config_entry.data[CONF_LONGITUDE] + self.event_unsub: CALLBACK_TYPE | None = None @property def calc_method(self) -> str: @@ -70,13 +73,14 @@ def school(self) -> str: def get_new_prayer_times(self) -> dict[str, Any]: """Fetch prayer times for today.""" calc = PrayerTimesCalculator( - latitude=self.hass.config.latitude, - longitude=self.hass.config.longitude, + latitude=self.latitude, + longitude=self.longitude, calculation_method=self.calc_method, latitudeAdjustmentMethod=self.lat_adj_method, 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 +149,12 @@ async def _async_update_data(self) -> dict[str, datetime]: 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 c87cb2d28ac047..7d2dd178788f8e 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/strings.json b/homeassistant/components/islamic_prayer_times/strings.json index e07a38ca1072a8..87703e5fdae5a7 100644 --- a/homeassistant/components/islamic_prayer_times/strings.json +++ b/homeassistant/components/islamic_prayer_times/strings.json @@ -8,7 +8,7 @@ } }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, "options": { diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index e451ef882b47d1..ebdef4146e0285 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -13,10 +13,10 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import _LOGGER, DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 7d7696755cfbb8..fec6c141915f2a 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -14,6 +14,7 @@ ) from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.service import entity_service_call @@ -120,6 +121,14 @@ def valid_isy_commands(value: Any) -> str: ) +def async_get_entities(hass: HomeAssistant) -> dict[str, Entity]: + """Get entities for a domain.""" + entities: dict[str, Entity] = {} + for platform in async_get_platforms(hass, DOMAIN): + entities.update(platform.entities) + return entities + + @callback def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Create and register services for the ISY integration.""" @@ -159,7 +168,7 @@ async def async_send_program_command_service_handler(service: ServiceCall) -> No async def _async_send_raw_node_command(call: ServiceCall) -> None: await entity_service_call( - hass, async_get_platforms(hass, DOMAIN), "async_send_raw_node_command", call + hass, async_get_entities(hass), "async_send_raw_node_command", call ) hass.services.async_register( @@ -171,7 +180,7 @@ async def _async_send_raw_node_command(call: ServiceCall) -> None: async def _async_send_node_command(call: ServiceCall) -> None: await entity_service_call( - hass, async_get_platforms(hass, DOMAIN), "async_send_node_command", call + hass, async_get_entities(hass), "async_send_node_command", call ) hass.services.async_register( @@ -183,7 +192,7 @@ async def _async_send_node_command(call: ServiceCall) -> None: async def _async_get_zwave_parameter(call: ServiceCall) -> None: await entity_service_call( - hass, async_get_platforms(hass, DOMAIN), "async_get_zwave_parameter", call + hass, async_get_entities(hass), "async_get_zwave_parameter", call ) hass.services.async_register( @@ -195,7 +204,7 @@ async def _async_get_zwave_parameter(call: ServiceCall) -> None: async def _async_set_zwave_parameter(call: ServiceCall) -> None: await entity_service_call( - hass, async_get_platforms(hass, DOMAIN), "async_set_zwave_parameter", call + hass, async_get_entities(hass), "async_set_zwave_parameter", call ) hass.services.async_register( @@ -207,7 +216,7 @@ async def _async_set_zwave_parameter(call: ServiceCall) -> None: async def _async_rename_node(call: ServiceCall) -> None: await entity_service_call( - hass, async_get_platforms(hass, DOMAIN), "async_rename_node", call + hass, async_get_entities(hass), "async_rename_node", call ) hass.services.async_register( diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index b39bad14d45e5a..ec7d78edd53bce 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -141,7 +141,7 @@ }, "rename_node": { "name": "Rename Node on ISY", - "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant Entity Name or Entity ID to match. The entity name and ID will only be updated after calling `isy994.reload` or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.", + "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant Entity Name or Entity ID to match. The entity name and ID will only be updated after reloading the integration or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.", "fields": { "name": { "name": "New Name", diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index de64741ba3a295..da208dcc79cca6 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -31,7 +31,7 @@ from .models import IsyData -@dataclass +@dataclass(frozen=True) class ISYSwitchEntityDescription(SwitchEntityDescription): """Describes IST switch.""" diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index cd0e9ab21a23b0..0f1afd30e9bedb 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -16,14 +16,14 @@ from .models import JellyfinData -@dataclass +@dataclass(frozen=True) class JellyfinSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[JellyfinDataT], StateType] -@dataclass +@dataclass(frozen=True) class JellyfinSensorEntityDescription( SensorEntityDescription, JellyfinSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index e127d78229fa4e..638d54d61598f6 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -22,14 +22,14 @@ from . import DOMAIN -@dataclass +@dataclass(frozen=True) class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): """Binary Sensor description mixin class for Jewish Calendar.""" is_on: Callable[..., bool] = lambda _: False -@dataclass +@dataclass(frozen=True) class JewishCalendarBinarySensorEntityDescription( JewishCalendarBinarySensorMixIns, BinarySensorEntityDescription ): diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py index e78f6189baf90a..fd2535c5bf391d 100644 --- a/homeassistant/components/juicenet/number.py +++ b/homeassistant/components/juicenet/number.py @@ -19,14 +19,14 @@ from .entity import JuiceNetDevice -@dataclass +@dataclass(frozen=True) class JuiceNetNumberEntityDescriptionMixin: """Mixin for required keys.""" setter_key: str -@dataclass +@dataclass(frozen=True) class JuiceNetNumberEntityDescription( NumberEntityDescription, JuiceNetNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index 156fa37e9820e7..cb428fa5eea482 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -29,14 +29,14 @@ from .entity import JustNimbusEntity -@dataclass +@dataclass(frozen=True) class JustNimbusEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[JustNimbusCoordinator], Any] -@dataclass +@dataclass(frozen=True) class JustNimbusEntityDescription( SensorEntityDescription, JustNimbusEntityDescriptionMixin ): diff --git a/homeassistant/components/kaiterra/sensor.py b/homeassistant/components/kaiterra/sensor.py index ab487aa1a25c35..bb780aab619171 100644 --- a/homeassistant/components/kaiterra/sensor.py +++ b/homeassistant/components/kaiterra/sensor.py @@ -17,14 +17,14 @@ from .const import DISPATCHER_KAITERRA, DOMAIN -@dataclass +@dataclass(frozen=True) class KaiterraSensorRequiredKeysMixin: """Mixin for required keys.""" suffix: str -@dataclass +@dataclass(frozen=True) class KaiterraSensorEntityDescription( SensorEntityDescription, KaiterraSensorRequiredKeysMixin ): diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index 183036f3973dfc..ba9eaca1e957e6 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -22,14 +22,14 @@ from homeassistant.helpers.typing import StateType -@dataclass +@dataclass(frozen=True) class BaseEntityDescriptionMixin: """Mixin for required descriptor keys.""" value_fn: Callable[[KaleidescapeDevice], StateType] -@dataclass +@dataclass(frozen=True) class KaleidescapeSensorEntityDescription( SensorEntityDescription, BaseEntityDescriptionMixin ): diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 13e3fabfbff192..765a3fc4d47c1b 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/kef/manifest.json b/homeassistant/components/kef/manifest.json index e2aec2fbcae9cf..29e398994f4350 100644 --- a/homeassistant/components/kef/manifest.json +++ b/homeassistant/components/kef/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/kef", "iot_class": "local_polling", "loggers": ["aiokef", "tenacity"], - "requirements": ["aiokef==0.2.16", "getmac==0.8.2"] + "requirements": ["aiokef==0.2.16", "getmac==0.9.4"] } diff --git a/homeassistant/components/kentuckypower/__init__.py b/homeassistant/components/kentuckypower/__init__.py new file mode 100644 index 00000000000000..cc4ab179682f88 --- /dev/null +++ b/homeassistant/components/kentuckypower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Kentucky Power.""" diff --git a/homeassistant/components/kentuckypower/manifest.json b/homeassistant/components/kentuckypower/manifest.json new file mode 100644 index 00000000000000..300cfd7dd9d605 --- /dev/null +++ b/homeassistant/components/kentuckypower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "kentuckypower", + "name": "Kentucky Power", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json index 2a3a3a40687705..6cecea12f223ba 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/knx/button.py b/homeassistant/components/knx/button.py index 274ced801464b5..94b5b51e401d09 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -6,18 +6,12 @@ from homeassistant import config_entries from homeassistant.components.button import ButtonEntity -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_PAYLOAD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_PAYLOAD, - CONF_PAYLOAD_LENGTH, - DATA_KNX_CONFIG, - DOMAIN, - KNX_ADDRESS, -) +from .const import CONF_PAYLOAD_LENGTH, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 519d5d0742d2b0..3d1e3c62a34cc7 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -68,7 +68,6 @@ CONF_KNX_SECURE_DEVICE_AUTHENTICATION: Final = "device_authentication" -CONF_PAYLOAD: Final = "payload" CONF_PAYLOAD_LENGTH: Final = "payload_length" CONF_RESET_AFTER: Final = "reset_after" CONF_RESPOND_TO_READ: Final = "respond_to_read" diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 60db7e95a65fd1..a22a16a6e69875 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -14,10 +14,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 8240fbaf3c13f7..c7bcd90538f0d1 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -37,6 +37,7 @@ CONF_EVENT, CONF_MODE, CONF_NAME, + CONF_PAYLOAD, CONF_TYPE, Platform, ) @@ -46,7 +47,6 @@ from .const import ( CONF_INVERT, CONF_KNX_EXPOSE, - CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, CONF_RESET_AFTER, CONF_RESPOND_TO_READ, diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 5baa068eaa6468..2852917e0219d0 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -9,6 +9,7 @@ from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_NAME, + CONF_PAYLOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, @@ -19,7 +20,6 @@ from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index dbfe8e9bd5e1ff..2f09f7e8ed603c 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -40,7 +40,7 @@ SCAN_INTERVAL = timedelta(seconds=10) -@dataclass +@dataclass(frozen=True) class KNXSystemEntityDescription(SensorEntityDescription): """Class describing KNX system sensor entities.""" diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 32ecbbed6260a2..89f0a992ff1def 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -231,7 +231,7 @@ async def async_setup_entry( def cmd( - func: Callable[Concatenate[_KodiEntityT, _P], Awaitable[Any]] + func: Callable[Concatenate[_KodiEntityT, _P], Awaitable[Any]], ) -> Callable[Concatenate[_KodiEntityT, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 51431b317d6488..7c7d53b33acd99 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/komfovent/__init__.py b/homeassistant/components/komfovent/__init__.py deleted file mode 100644 index 0366a429b21298..00000000000000 --- a/homeassistant/components/komfovent/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -"""The Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import DOMAIN - -PLATFORMS: list[Platform] = [Platform.CLIMATE] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Komfovent from a config entry.""" - host = entry.data[CONF_HOST] - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - _, credentials = komfovent_api.get_credentials(host, username, password) - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - raise ConfigEntryNotReady(f"Unable to connect to {host}: {result}") - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (credentials, settings) - - 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/komfovent/climate.py b/homeassistant/components/komfovent/climate.py deleted file mode 100644 index 2e51fddf4f2d96..00000000000000 --- a/homeassistant/components/komfovent/climate.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Ventilation Units from Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.components.climate import ( - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -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 .const import DOMAIN - -HASS_TO_KOMFOVENT_MODES = { - HVACMode.COOL: komfovent_api.KomfoventModes.COOL, - HVACMode.HEAT_COOL: komfovent_api.KomfoventModes.HEAT_COOL, - HVACMode.OFF: komfovent_api.KomfoventModes.OFF, - HVACMode.AUTO: komfovent_api.KomfoventModes.AUTO, -} -KOMFOVENT_TO_HASS_MODES = {v: k for k, v in HASS_TO_KOMFOVENT_MODES.items()} - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Komfovent unit control.""" - credentials, settings = hass.data[DOMAIN][entry.entry_id] - async_add_entities([KomfoventDevice(credentials, settings)], True) - - -class KomfoventDevice(ClimateEntity): - """Representation of a ventilation unit.""" - - _attr_hvac_modes = list(HASS_TO_KOMFOVENT_MODES.keys()) - _attr_preset_modes = [mode.name for mode in komfovent_api.KomfoventPresets] - _attr_supported_features = ClimateEntityFeature.PRESET_MODE - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, - credentials: komfovent_api.KomfoventCredentials, - settings: komfovent_api.KomfoventSettings, - ) -> None: - """Initialize the ventilation unit.""" - self._komfovent_credentials = credentials - self._komfovent_settings = settings - - self._attr_unique_id = settings.serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, settings.serial_number)}, - model=settings.model, - name=settings.name, - serial_number=settings.serial_number, - sw_version=settings.version, - manufacturer="Komfovent", - ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new target preset mode.""" - await komfovent_api.set_preset( - self._komfovent_credentials, - komfovent_api.KomfoventPresets[preset_mode], - ) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - await komfovent_api.set_mode( - self._komfovent_credentials, HASS_TO_KOMFOVENT_MODES[hvac_mode] - ) - - async def async_update(self) -> None: - """Get the latest data.""" - result, status = await komfovent_api.get_unit_status( - self._komfovent_credentials - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS or not status: - self._attr_available = False - return - self._attr_available = True - self._attr_preset_mode = status.preset - self._attr_current_temperature = status.temp_extract - self._attr_hvac_mode = KOMFOVENT_TO_HASS_MODES[status.mode] diff --git a/homeassistant/components/komfovent/config_flow.py b/homeassistant/components/komfovent/config_flow.py deleted file mode 100644 index fb5390a30c6642..00000000000000 --- a/homeassistant/components/komfovent/config_flow.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Config flow for Komfovent integration.""" -from __future__ import annotations - -import logging -from typing import Any - -import komfovent_api -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER = "user" -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_USERNAME, default="user"): str, - vol.Required(CONF_PASSWORD): str, - } -) - -ERRORS_MAP = { - komfovent_api.KomfoventConnectionResult.NOT_FOUND: "cannot_connect", - komfovent_api.KomfoventConnectionResult.UNAUTHORISED: "invalid_auth", - komfovent_api.KomfoventConnectionResult.INVALID_INPUT: "invalid_input", -} - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Komfovent.""" - - VERSION = 1 - - def __return_error( - self, result: komfovent_api.KomfoventConnectionResult - ) -> FlowResult: - return self.async_show_form( - step_id=STEP_USER, - data_schema=STEP_USER_DATA_SCHEMA, - errors={"base": ERRORS_MAP.get(result, "unknown")}, - ) - - 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=STEP_USER, data_schema=STEP_USER_DATA_SCHEMA - ) - - conf_host = user_input[CONF_HOST] - conf_username = user_input[CONF_USERNAME] - conf_password = user_input[CONF_PASSWORD] - - result, credentials = komfovent_api.get_credentials( - conf_host, conf_username, conf_password - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - await self.async_set_unique_id(settings.serial_number) - self._abort_if_unique_id_configured() - - return self.async_create_entry(title=settings.name, data=user_input) diff --git a/homeassistant/components/komfovent/const.py b/homeassistant/components/komfovent/const.py deleted file mode 100644 index a7881a58c414cf..00000000000000 --- a/homeassistant/components/komfovent/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Komfovent integration.""" - -DOMAIN = "komfovent" diff --git a/homeassistant/components/komfovent/manifest.json b/homeassistant/components/komfovent/manifest.json deleted file mode 100644 index cbe00ef8dc5767..00000000000000 --- a/homeassistant/components/komfovent/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "komfovent", - "name": "Komfovent", - "codeowners": ["@ProstoSanja"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/komfovent", - "iot_class": "local_polling", - "requirements": ["komfovent-api==0.0.3"] -} diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index 1c495ac9db938f..adb1bfb6f09b6a 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -3,13 +3,18 @@ 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 @@ def client(self) -> ApiClient: 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 @@ def stop_fetch_data(self, module_id: str, data_id: str) -> None: class ProcessDataUpdateCoordinator( - PlenticoreUpdateCoordinator[dict[str, dict[str, str]]] + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]] ): """Implementation of PlenticoreUpdateCoordinator for process data.""" @@ -206,18 +213,19 @@ async def _async_update_data(self) -> dict[str, dict[str, str]]: 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 95f4a1949775f2..d65368e7ee44de 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/number.py b/homeassistant/components/kostal_plenticore/number.py index 834057d63b8023..36e1fc95eb8757 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreNumberEntityDescriptionMixin: """Define an entity description mixin for number entities.""" @@ -36,7 +36,7 @@ class PlenticoreNumberEntityDescriptionMixin: fmt_to: str -@dataclass +@dataclass(frozen=True) class PlenticoreNumberEntityDescription( NumberEntityDescription, PlenticoreNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 779cc24b0c4c79..321bc4e5d700e3 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -19,14 +19,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreRequiredKeysMixin: """A class that describes required properties for plenticore select entities.""" module_id: str -@dataclass +@dataclass(frozen=True) class PlenticoreSelectEntityDescription( SelectEntityDescription, PlenticoreRequiredKeysMixin ): diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index f7bad638df4ff2..111d497b12899c 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -33,7 +33,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreRequiredKeysMixin: """A class that describes required properties for plenticore sensor entities.""" @@ -41,7 +41,7 @@ class PlenticoreRequiredKeysMixin: formatter: str -@dataclass +@dataclass(frozen=True) class PlenticoreSensorEntityDescription( SensorEntityDescription, PlenticoreRequiredKeysMixin ): @@ -649,6 +649,39 @@ class PlenticoreSensorEntityDescription( 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 @@ class PlenticoreSensorEntityDescription( 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/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 554f8db2b68eb8..509a36108845d0 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreRequiredKeysMixin: """A class that describes required properties for plenticore switch entities.""" @@ -32,7 +32,7 @@ class PlenticoreRequiredKeysMixin: off_label: str -@dataclass +@dataclass(frozen=True) class PlenticoreSwitchEntityDescription( SwitchEntityDescription, PlenticoreRequiredKeysMixin ): diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index a6c00e62b62a20..7e55da2b1898bd 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -32,14 +32,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class KrakenRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[DataUpdateCoordinator[KrakenResponse], str], float | int] -@dataclass +@dataclass(frozen=True) class KrakenSensorEntityDescription(SensorEntityDescription, KrakenRequiredKeysMixin): """Describes Kraken sensor entity.""" @@ -259,7 +259,8 @@ def _update_internal_state(self) -> None: 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 7355a60f5f0fed..40d38da55ebc31 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/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 76688af61aeb8b..960ab0ff325268 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace import logging from lacrosse_view import Sensor @@ -35,14 +35,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class LaCrosseSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Sensor, str], float | int | str | None] -@dataclass +@dataclass(frozen=True) class LaCrosseSensorEntityDescription( SensorEntityDescription, LaCrosseSensorEntityDescriptionMixin ): @@ -97,7 +97,7 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: key="Rain", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, - native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), "WindHeading": LaCrosseSensorEntityDescription( @@ -130,7 +130,7 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), "WindChill": LaCrosseSensorEntityDescription( key="WindChill", @@ -138,9 +138,18 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), } +# map of API returned unit of measurement strings to their corresponding unit of measurement +UNIT_OF_MEASUREMENT_MAP = { + "degrees_celsius": UnitOfTemperature.CELSIUS, + "degrees_fahrenheit": UnitOfTemperature.FAHRENHEIT, + "inches": UnitOfPrecipitationDepth.INCHES, + "millimeters": UnitOfPrecipitationDepth.MILLIMETERS, + "kilometers_per_hour": UnitOfSpeed.KILOMETERS_PER_HOUR, + "miles_per_hour": UnitOfSpeed.MILES_PER_HOUR, +} async def async_setup_entry( @@ -171,6 +180,19 @@ async def async_setup_entry( _LOGGER.warning(message) continue + + # if the API returns a different unit of measurement from the description, update it + if sensor.data.get(field) is not None: + native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAP.get( + sensor.data[field].get("unit") + ) + + if native_unit_of_measurement is not None: + description = replace( + description, + native_unit_of_measurement=native_unit_of_measurement, + ) + sensor_list.append( LaCrosseViewSensor( coordinator=coordinator, diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 18a0c2f8f72841..dacbf8d244575c 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -19,20 +19,13 @@ from .helpers import lametric_exception_handler -@dataclass -class LaMetricButtonEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(frozen=True, 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/helpers.py b/homeassistant/components/lametric/helpers.py index 884e6c451bc9f9..3a3014a369e22c 100644 --- a/homeassistant/components/lametric/helpers.py +++ b/homeassistant/components/lametric/helpers.py @@ -19,7 +19,7 @@ def lametric_exception_handler( - func: Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, Any]] + func: Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, None]]: """Decorate LaMetric calls to handle LaMetric exceptions. diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index da458cab61ebc6..9acdc6f1411dae 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -19,21 +19,14 @@ from .helpers import lametric_exception_handler -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(frozen=True, 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 b7c0e55745eba7..c7a3f55125ba9e 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -19,21 +19,14 @@ from .helpers import lametric_exception_handler -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(frozen=True, 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 6cddf81b2bf3f6..5ef3608d33b39f 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -21,20 +21,13 @@ from .entity import LaMetricEntity -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(frozen=True, 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 4069cb41bddf09..87bda01e305151 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -44,8 +44,8 @@ "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]" + "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 c33ec16d617fc5..7fda3a22b8f0b3 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -19,21 +19,13 @@ 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(frozen=True, 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/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index 4f7966ae90f4b2..7d03ed2efafdf6 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -108,7 +108,7 @@ async def validate_ultraheat(self, port: str) -> tuple[str, str]: # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) - except (asyncio.TimeoutError, serial.serialutil.SerialException) as err: + except (asyncio.TimeoutError, serial.SerialException) as err: _LOGGER.warning("Failed read data from: %s. %s", port, err) raise CannotConnect(f"Error communicating with device: {err}") from err diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index 27231dc7b927d1..db265449f3719b 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -33,5 +33,5 @@ async def _async_update_data(self) -> HeatMeterResponse: try: async with asyncio.timeout(ULTRAHEAT_TIMEOUT): return await self.hass.async_add_executor_job(self.api.read) - except (FileNotFoundError, serial.serialutil.SerialException) as err: + except (FileNotFoundError, serial.SerialException) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 8ef81e899b7aee..075aeb67b5040c 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -39,14 +39,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class HeatMeterSensorEntityDescriptionMixin: """Mixin for additional Heat Meter sensor description attributes .""" value_fn: Callable[[HeatMeterResponse], StateType | datetime] -@dataclass +@dataclass(frozen=True) class HeatMeterSensorEntityDescription( SensorEntityDescription, HeatMeterSensorEntityDescriptionMixin ): @@ -316,7 +316,9 @@ def __init__( """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/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 5dab7da56ede8d..2c1934f0c16264 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -31,7 +31,7 @@ DEFAULT_NEXT_LAUNCH_NAME = "Next launch" -@dataclass +@dataclass(frozen=True) class LaunchLibrarySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -39,7 +39,7 @@ class LaunchLibrarySensorEntityDescriptionMixin: attributes_fn: Callable[[Launch | Event], dict[str, Any] | None] -@dataclass +@dataclass(frozen=True) class LaunchLibrarySensorEntityDescription( SensorEntityDescription, LaunchLibrarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index 5388463316f3ce..b1eac0a6609b3e 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -1,10 +1,9 @@ """The lawn mower integration.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -25,6 +24,12 @@ LawnMowerEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -65,12 +70,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class LawnMowerEntityEntityDescription(EntityDescription): +class LawnMowerEntityEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes lawn mower entities.""" -class LawnMowerEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "activity", + "supported_features", +} + + +class LawnMowerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for lawn mower entities.""" entity_description: LawnMowerEntityEntityDescription @@ -85,12 +95,12 @@ def state(self) -> str | None: return None return str(activity) - @property + @cached_property def activity(self) -> LawnMowerActivity | None: """Return the current lawn mower activity.""" return self._attr_activity - @property + @cached_property def supported_features(self) -> LawnMowerEntityFeature: """Flag lawn mower features that are supported.""" return self._attr_supported_features diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index bb97658b880d94..e8da5b3907380b 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -21,7 +21,6 @@ CONF_HARDWARE_SERIAL = "hardware_serial" CONF_SOFTWARE_SERIAL = "software_serial" CONF_HARDWARE_TYPE = "hardware_type" -CONF_RESOURCE = "resource" CONF_DOMAIN_DATA = "domain_data" CONF_CONNECTIONS = "connections" diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index e190b25eded2b1..64a789f3a34b05 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -24,6 +24,7 @@ CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_RESOURCE, CONF_SENSORS, CONF_SOURCE, CONF_SWITCHES, @@ -42,7 +43,6 @@ CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, CONF_OUTPUT, - CONF_RESOURCE, CONF_SCENES, CONF_SK_NUM_TRIES, CONF_SOFTWARE_SERIAL, diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 7996376b6ac9d1..8b220f78e5300b 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.19.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 21543ad67887a5..9a496dbd049f12 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.19.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/lg_soundbar/strings.json b/homeassistant/components/lg_soundbar/strings.json index 8c6a9909ff58c1..ee16a39350c04c 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/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 1a2930c8051709..027779f93fe180 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from copy import deepcopy -from dataclasses import dataclass +import dataclasses from typing import Any, Generic from aiopyarr import LidarrQueue, LidarrQueueItem, LidarrRootFolder @@ -40,21 +39,23 @@ def get_modified_description( description: LidarrSensorEntityDescription[T], mount: LidarrRootFolder ) -> tuple[LidarrSensorEntityDescription[T], str]: """Return modified description and folder name.""" - desc = deepcopy(description) name = mount.path.rsplit("/")[-1].rsplit("\\")[-1] - desc.key = f"{description.key}_{name}" - desc.name = f"{description.name} {name}".capitalize() + desc = dataclasses.replace( + description, + key=f"{description.key}_{name}", + name=f"{description.name} {name}".capitalize(), + ) return desc, name -@dataclass +@dataclasses.dataclass(frozen=True) class LidarrSensorEntityDescriptionMixIn(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T, str], str | int] -@dataclass +@dataclasses.dataclass(frozen=True) class LidarrSensorEntityDescription( SensorEntityDescription, LidarrSensorEntityDescriptionMixIn[T], Generic[T] ): diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index c6e0fad14c64ef..8bd0895821b390 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -2,128 +2,22 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any -import voluptuous as vol - -from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_EXCLUDE, - CONF_INCLUDE, - CONF_PASSWORD, - CONF_PREFIX, - CONF_USERNAME, - Platform, -) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType - -from .const import ( - CONF_CIRCLES, - CONF_DRIVING_SPEED, - CONF_ERROR_THRESHOLD, - CONF_MAX_GPS_ACCURACY, - CONF_MAX_UPDATE_WAIT, - CONF_MEMBERS, - CONF_SHOW_AS_STATE, - CONF_WARNING_THRESHOLD, - DEFAULT_OPTIONS, - DOMAIN, - LOGGER, - SHOW_DRIVING, - SHOW_MOVING, -) + +from .const import DOMAIN from .coordinator import Life360DataUpdateCoordinator, MissingLocReason PLATFORMS = [Platform.DEVICE_TRACKER, Platform.BUTTON] -CONF_ACCOUNTS = "accounts" - -SHOW_AS_STATE_OPTS = [SHOW_DRIVING, SHOW_MOVING] - - -def _show_as_state(config: dict) -> dict: - if opts := config.pop(CONF_SHOW_AS_STATE): - if SHOW_DRIVING in opts: - config[SHOW_DRIVING] = True - if SHOW_MOVING in opts: - LOGGER.warning( - "%s is no longer supported as an option for %s", - SHOW_MOVING, - CONF_SHOW_AS_STATE, - ) - return config - - -def _unsupported(unsupported: set[str]) -> Callable[[dict], dict]: - """Warn about unsupported options and remove from config.""" - - def validator(config: dict) -> dict: - if unsupported_keys := unsupported & set(config): - LOGGER.warning( - "The following options are no longer supported: %s", - ", ".join(sorted(unsupported_keys)), - ) - return {k: v for k, v in config.items() if k not in unsupported} - - return validator - - -ACCOUNT_SCHEMA = { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, -} -CIRCLES_MEMBERS = { - vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), -} -LIFE360_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_ACCOUNTS): vol.All(cv.ensure_list, [ACCOUNT_SCHEMA]), - vol.Optional(CONF_CIRCLES): CIRCLES_MEMBERS, - vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), - vol.Optional(CONF_ERROR_THRESHOLD): vol.Coerce(int), - vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), - vol.Optional(CONF_MAX_UPDATE_WAIT): cv.time_period, - vol.Optional(CONF_MEMBERS): CIRCLES_MEMBERS, - vol.Optional(CONF_PREFIX): vol.Any(None, cv.string), - vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, - vol.Optional(CONF_SHOW_AS_STATE, default=[]): vol.All( - cv.ensure_list, [vol.In(SHOW_AS_STATE_OPTS)] - ), - vol.Optional(CONF_WARNING_THRESHOLD): vol.Coerce(int), - } - ), - _unsupported( - { - CONF_ACCOUNTS, - CONF_CIRCLES, - CONF_ERROR_THRESHOLD, - CONF_MAX_UPDATE_WAIT, - CONF_MEMBERS, - CONF_PREFIX, - CONF_SCAN_INTERVAL, - CONF_WARNING_THRESHOLD, - } - ), - _show_as_state, -) -CONFIG_SCHEMA = vol.Schema( - vol.All({DOMAIN: LIFE360_SCHEMA}, cv.removed(DOMAIN, raise_if_present=False)), - extra=vol.ALLOW_EXTRA, -) - @dataclass class IntegData: """Integration data.""" - cfg_options: dict[str, Any] | None = None # ConfigEntry.entry_id: Life360DataUpdateCoordinator coordinators: dict[str, Life360DataUpdateCoordinator] = field( init=False, default_factory=dict @@ -137,34 +31,13 @@ class IntegData: logged_circles: list[str] = field(init=False, default_factory=list) logged_places: list[str] = field(init=False, default_factory=list) - def __post_init__(self): - """Finish initialization of cfg_options.""" - self.cfg_options = self.cfg_options or {} - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up integration.""" - hass.data.setdefault(DOMAIN, IntegData(config.get(DOMAIN))) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up config entry.""" hass.data.setdefault(DOMAIN, IntegData()) - # Check if this entry was created when this was a "legacy" tracker. If it was, - # update with missing data. - if not entry.unique_id: - hass.config_entries.async_update_entry( - entry, - unique_id=entry.data[CONF_USERNAME].lower(), - options=DEFAULT_OPTIONS | hass.data[DOMAIN].cfg_options, - ) - coordinator = Life360DataUpdateCoordinator(hass, entry) - await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN].coordinators[entry.entry_id] = coordinator # Set up components for our platforms. diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py index 333ce14fbf6fa1..d310a5177b1d62 100644 --- a/homeassistant/components/life360/const.py +++ b/homeassistant/components/life360/const.py @@ -24,17 +24,10 @@ ATTR_WIFI_ON = "wifi_on" CONF_AUTHORIZATION = "authorization" -CONF_CIRCLES = "circles" CONF_DRIVING_SPEED = "driving_speed" -CONF_ERROR_THRESHOLD = "error_threshold" CONF_MAX_GPS_ACCURACY = "max_gps_accuracy" -CONF_MAX_UPDATE_WAIT = "max_update_wait" -CONF_MEMBERS = "members" -CONF_SHOW_AS_STATE = "show_as_state" -CONF_WARNING_THRESHOLD = "warning_threshold" SHOW_DRIVING = "driving" -SHOW_MOVING = "moving" DEFAULT_OPTIONS = { CONF_DRIVING_SPEED: None, diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py index 755fa1b812434d..4ef6e20d703a10 100644 --- a/homeassistant/components/life360/coordinator.py +++ b/homeassistant/components/life360/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from contextlib import suppress from dataclasses import dataclass, field from datetime import datetime @@ -130,8 +131,10 @@ async def _async_update_data(self) -> Life360Data: for circle in await self._retrieve_data("get_circles"): circle_id = circle["id"] - circle_members = await self._retrieve_data("get_circle_members", circle_id) - circle_places = await self._retrieve_data("get_circle_places", circle_id) + circle_members, circle_places = await asyncio.gather( + self._retrieve_data("get_circle_members", circle_id), + self._retrieve_data("get_circle_places", circle_id), + ) data.circles[circle_id] = Life360Circle( circle["name"], diff --git a/homeassistant/components/life360/manifest.json b/homeassistant/components/life360/manifest.json index 18b83013d70bcd..481d006809db71 100644 --- a/homeassistant/components/life360/manifest.json +++ b/homeassistant/components/life360/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/life360", "iot_class": "cloud_polling", "loggers": ["life360"], - "requirements": ["life360==6.0.0"] + "requirements": ["life360==6.0.1"] } diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 7cabfd4712f4d5..39412780331b12 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 c327081fabd85b..21f3b3fe52b67f 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/__init__.py b/homeassistant/components/light/__init__.py index 78cccde5890f50..6307b41f557c9b 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -8,7 +8,7 @@ from enum import IntFlag, StrEnum import logging import os -from typing import Any, Self, cast, final +from typing import TYPE_CHECKING, Any, Self, cast, final import voluptuous as vol @@ -33,6 +33,11 @@ from homeassistant.loader import bind_hass import homeassistant.util.color as color_util +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + DOMAIN = "light" SCAN_INTERVAL = timedelta(seconds=30) DATA_PROFILES = "light_profiles" @@ -155,14 +160,14 @@ def brightness_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: """Test if brightness is supported.""" if not color_modes: return False - return any(mode in COLOR_MODES_BRIGHTNESS for mode in color_modes) + return not COLOR_MODES_BRIGHTNESS.isdisjoint(color_modes) def color_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: """Test if color is supported.""" if not color_modes: return False - return any(mode in COLOR_MODES_COLOR for mode in color_modes) + return not COLOR_MODES_COLOR.isdisjoint(color_modes) def color_temp_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: @@ -340,11 +345,14 @@ def filter_turn_off_params( light: LightEntity, params: dict[str, Any] ) -> dict[str, Any]: """Filter out params not used in turn off or not supported by the light.""" - supported_features = light.supported_features + if not params: + return params + + supported_features = light.supported_features_compat - if not supported_features & LightEntityFeature.FLASH: + if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) - if not supported_features & LightEntityFeature.TRANSITION: + if LightEntityFeature.TRANSITION not in supported_features: params.pop(ATTR_TRANSITION, None) return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)} @@ -352,13 +360,13 @@ def filter_turn_off_params( def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]: """Filter out params not supported by the light.""" - supported_features = light.supported_features + supported_features = light.supported_features_compat - if not supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT not in supported_features: params.pop(ATTR_EFFECT, None) - if not supported_features & LightEntityFeature.FLASH: + if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) - if not supported_features & LightEntityFeature.TRANSITION: + if LightEntityFeature.TRANSITION not in supported_features: params.pop(ATTR_TRANSITION, None) supported_color_modes = ( @@ -500,6 +508,14 @@ async def async_handle_light_on_service( # noqa: C901 ) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_hs_to_xy(*hs_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: assert (rgb_color := params.pop(ATTR_RGB_COLOR)) is not None if ColorMode.RGBW in supported_color_modes: @@ -515,6 +531,14 @@ async def async_handle_light_on_service( # noqa: C901 params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: xy_color = params.pop(ATTR_XY_COLOR) if ColorMode.HS in supported_color_modes: @@ -529,6 +553,13 @@ async def async_handle_light_on_service( # noqa: C901 params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) + elif ColorMode.COLOR_TEMP in supported_color_modes: + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: rgbw_color = params.pop(ATTR_RGBW_COLOR) rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) @@ -542,6 +573,14 @@ async def async_handle_light_on_service( # noqa: C901 params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ( ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes ): @@ -558,6 +597,14 @@ async def async_handle_light_on_service( # noqa: C901 params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) # If white is set to True, set it to the light's brightness # Add a warning in Home Assistant Core 2023.5 if the brightness is set to an @@ -777,12 +824,29 @@ def apply_profile(self, name: str, params: dict[str, Any]) -> None: params.setdefault(ATTR_TRANSITION, profile.transition) -@dataclasses.dataclass -class LightEntityDescription(ToggleEntityDescription): +class LightEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes binary sensor entities.""" -class LightEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "brightness", + "color_mode", + "hs_color", + "xy_color", + "rgb_color", + "rgbw_color", + "rgbww_color", + "color_temp", + "min_mireds", + "max_mireds", + "effect_list", + "effect", + "supported_color_modes", + "supported_features", +} + + +class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for light entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -817,12 +881,12 @@ class LightEntity(ToggleEntity): _attr_supported_features: LightEntityFeature = LightEntityFeature(0) _attr_xy_color: tuple[float, float] | None = None - @property + @cached_property def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" return self._attr_brightness - @property + @cached_property def color_mode(self) -> ColorMode | str | None: """Return the color mode of the light.""" return self._attr_color_mode @@ -847,22 +911,22 @@ def _light_internal_color_mode(self) -> str: return color_mode - @property + @cached_property def hs_color(self) -> tuple[float, float] | None: """Return the hue and saturation color value [float, float].""" return self._attr_hs_color - @property + @cached_property def xy_color(self) -> tuple[float, float] | None: """Return the xy color value [float, float].""" return self._attr_xy_color - @property + @cached_property def rgb_color(self) -> tuple[int, int, int] | None: """Return the rgb color value [int, int, int].""" return self._attr_rgb_color - @property + @cached_property def rgbw_color(self) -> tuple[int, int, int, int] | None: """Return the rgbw color value [int, int, int, int].""" return self._attr_rgbw_color @@ -873,12 +937,12 @@ def _light_internal_rgbw_color(self) -> tuple[int, int, int, int] | None: rgbw_color = self.rgbw_color return rgbw_color - @property + @cached_property def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return the rgbww color value [int, int, int, int, int].""" return self._attr_rgbww_color - @property + @cached_property def color_temp(self) -> int | None: """Return the CT color value in mireds.""" return self._attr_color_temp @@ -886,16 +950,16 @@ def color_temp(self) -> int | None: @property def color_temp_kelvin(self) -> int | None: """Return the CT color value in Kelvin.""" - if self._attr_color_temp_kelvin is None and self.color_temp: - return color_util.color_temperature_mired_to_kelvin(self.color_temp) + if self._attr_color_temp_kelvin is None and (color_temp := self.color_temp): + return color_util.color_temperature_mired_to_kelvin(color_temp) return self._attr_color_temp_kelvin - @property + @cached_property def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" return self._attr_min_mireds - @property + @cached_property def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" return self._attr_max_mireds @@ -914,12 +978,12 @@ def max_color_temp_kelvin(self) -> int: return color_util.color_temperature_mired_to_kelvin(self.min_mireds) return self._attr_max_color_temp_kelvin - @property + @cached_property def effect_list(self) -> list[str] | None: """Return the list of supported effects.""" return self._attr_effect_list - @property + @cached_property def effect(self) -> str | None: """Return the current effect.""" return self._attr_effect @@ -928,25 +992,27 @@ def effect(self) -> str | None: def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self._light_internal_supported_color_modes if ColorMode.COLOR_TEMP in supported_color_modes: - data[ATTR_MIN_COLOR_TEMP_KELVIN] = self.min_color_temp_kelvin - data[ATTR_MAX_COLOR_TEMP_KELVIN] = self.max_color_temp_kelvin - if not self.max_color_temp_kelvin: + min_color_temp_kelvin = self.min_color_temp_kelvin + max_color_temp_kelvin = self.max_color_temp_kelvin + data[ATTR_MIN_COLOR_TEMP_KELVIN] = min_color_temp_kelvin + data[ATTR_MAX_COLOR_TEMP_KELVIN] = max_color_temp_kelvin + if not max_color_temp_kelvin: data[ATTR_MIN_MIREDS] = None else: data[ATTR_MIN_MIREDS] = color_util.color_temperature_kelvin_to_mired( - self.max_color_temp_kelvin + max_color_temp_kelvin ) - if not self.min_color_temp_kelvin: + if not min_color_temp_kelvin: data[ATTR_MAX_MIREDS] = None else: data[ATTR_MAX_MIREDS] = color_util.color_temperature_kelvin_to_mired( - self.min_color_temp_kelvin + min_color_temp_kelvin ) - if supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT_LIST] = self.effect_list data[ATTR_SUPPORTED_COLOR_MODES] = sorted(supported_color_modes) @@ -957,30 +1023,27 @@ def _light_internal_convert_color( self, color_mode: ColorMode | str ) -> dict[str, tuple[float, ...]]: data: dict[str, tuple[float, ...]] = {} - if color_mode == ColorMode.HS and self.hs_color: - hs_color = self.hs_color + if color_mode == ColorMode.HS and (hs_color := self.hs_color): data[ATTR_HS_COLOR] = (round(hs_color[0], 3), round(hs_color[1], 3)) data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) data[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) - elif color_mode == ColorMode.XY and self.xy_color: - xy_color = self.xy_color + elif color_mode == ColorMode.XY and (xy_color := self.xy_color): data[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color) data[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color) data[ATTR_XY_COLOR] = (round(xy_color[0], 6), round(xy_color[1], 6)) - elif color_mode == ColorMode.RGB and self.rgb_color: - rgb_color = self.rgb_color + elif color_mode == ColorMode.RGB and (rgb_color := self.rgb_color): data[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif color_mode == ColorMode.RGBW and self._light_internal_rgbw_color: - rgbw_color = self._light_internal_rgbw_color + elif color_mode == ColorMode.RGBW and ( + rgbw_color := self._light_internal_rgbw_color + ): rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) data[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) data[ATTR_RGBW_COLOR] = tuple(int(x) for x in rgbw_color[0:4]) data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif color_mode == ColorMode.RGBWW and self.rgbww_color: - rgbww_color = self.rgbww_color + elif color_mode == ColorMode.RGBWW and (rgbww_color := self.rgbww_color): rgb_color = color_util.color_rgbww_to_rgb( *rgbww_color, self.min_color_temp_kelvin, self.max_color_temp_kelvin ) @@ -988,8 +1051,10 @@ def _light_internal_convert_color( data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) data[ATTR_RGBWW_COLOR] = tuple(int(x) for x in rgbww_color[0:5]) data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif color_mode == ColorMode.COLOR_TEMP and self.color_temp_kelvin: - hs_color = color_util.color_temperature_to_hs(self.color_temp_kelvin) + elif color_mode == ColorMode.COLOR_TEMP and ( + color_temp_kelvin := self.color_temp_kelvin + ): + hs_color = color_util.color_temperature_to_hs(color_temp_kelvin) data[ATTR_HS_COLOR] = (round(hs_color[0], 3), round(hs_color[1], 3)) data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) data[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) @@ -1000,99 +1065,103 @@ def _light_internal_convert_color( def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features - supported_color_modes = self._light_internal_supported_color_modes - color_mode = self._light_internal_color_mode if self.is_on else None + supported_features = self.supported_features_compat + supported_color_modes = self.supported_color_modes + legacy_supported_color_modes = ( + supported_color_modes or self._light_internal_supported_color_modes + ) + supported_features_value = supported_features.value + _is_on = self.is_on + color_mode = self._light_internal_color_mode if _is_on else None - if color_mode and color_mode not in supported_color_modes: + if color_mode and color_mode not in legacy_supported_color_modes: # Increase severity to warning in 2021.6, reject in 2021.10 _LOGGER.debug( "%s: set to unsupported color_mode: %s, supported_color_modes: %s", self.entity_id, color_mode, - supported_color_modes, + legacy_supported_color_modes, ) data[ATTR_COLOR_MODE] = color_mode - if brightness_supported(self.supported_color_modes): + if brightness_supported(supported_color_modes): if color_mode in COLOR_MODES_BRIGHTNESS: data[ATTR_BRIGHTNESS] = self.brightness else: data[ATTR_BRIGHTNESS] = None - elif supported_features & SUPPORT_BRIGHTNESS: + elif supported_features_value & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states # Add warning in 2021.6, remove in 2021.10 - if self.is_on: + if _is_on: data[ATTR_BRIGHTNESS] = self.brightness else: data[ATTR_BRIGHTNESS] = None - if color_temp_supported(self.supported_color_modes): + if color_temp_supported(supported_color_modes): if color_mode == ColorMode.COLOR_TEMP: - data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin - if self.color_temp_kelvin: + color_temp_kelvin = self.color_temp_kelvin + data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin + if color_temp_kelvin: data[ ATTR_COLOR_TEMP - ] = color_util.color_temperature_kelvin_to_mired( - self.color_temp_kelvin - ) + ] = color_util.color_temperature_kelvin_to_mired(color_temp_kelvin) else: data[ATTR_COLOR_TEMP] = None else: data[ATTR_COLOR_TEMP_KELVIN] = None data[ATTR_COLOR_TEMP] = None - elif supported_features & SUPPORT_COLOR_TEMP: + elif supported_features_value & SUPPORT_COLOR_TEMP: # Backwards compatibility # Add warning in 2021.6, remove in 2021.10 - if self.is_on: - data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin - if self.color_temp_kelvin: + if _is_on: + color_temp_kelvin = self.color_temp_kelvin + data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin + if color_temp_kelvin: data[ ATTR_COLOR_TEMP - ] = color_util.color_temperature_kelvin_to_mired( - self.color_temp_kelvin - ) + ] = color_util.color_temperature_kelvin_to_mired(color_temp_kelvin) else: data[ATTR_COLOR_TEMP] = None else: data[ATTR_COLOR_TEMP_KELVIN] = None data[ATTR_COLOR_TEMP] = None - if color_supported(supported_color_modes) or color_temp_supported( - supported_color_modes + if color_supported(legacy_supported_color_modes) or color_temp_supported( + legacy_supported_color_modes ): data[ATTR_HS_COLOR] = None data[ATTR_RGB_COLOR] = None data[ATTR_XY_COLOR] = None - if ColorMode.RGBW in supported_color_modes: + if ColorMode.RGBW in legacy_supported_color_modes: data[ATTR_RGBW_COLOR] = None - if ColorMode.RGBWW in supported_color_modes: + if ColorMode.RGBWW in legacy_supported_color_modes: data[ATTR_RGBWW_COLOR] = None if color_mode: data.update(self._light_internal_convert_color(color_mode)) - if supported_features & LightEntityFeature.EFFECT: - data[ATTR_EFFECT] = self.effect if self.is_on else None + if LightEntityFeature.EFFECT in supported_features: + data[ATTR_EFFECT] = self.effect if _is_on else None return data @property def _light_internal_supported_color_modes(self) -> set[ColorMode] | set[str]: """Calculate supported color modes with backwards compatibility.""" - if self.supported_color_modes is not None: - return self.supported_color_modes + if (_supported_color_modes := self.supported_color_modes) is not None: + return _supported_color_modes # Backwards compatibility for supported_color_modes added in 2021.4 # Add warning in 2021.6, remove in 2021.10 - supported_features = self.supported_features + supported_features = self.supported_features_compat + supported_features_value = supported_features.value supported_color_modes: set[ColorMode] = set() - if supported_features & SUPPORT_COLOR_TEMP: + if supported_features_value & SUPPORT_COLOR_TEMP: supported_color_modes.add(ColorMode.COLOR_TEMP) - if supported_features & SUPPORT_COLOR: + if supported_features_value & SUPPORT_COLOR: supported_color_modes.add(ColorMode.HS) - if supported_features & SUPPORT_BRIGHTNESS and not supported_color_modes: + if not supported_color_modes and supported_features_value & SUPPORT_BRIGHTNESS: supported_color_modes = {ColorMode.BRIGHTNESS} if not supported_color_modes: @@ -1100,12 +1169,43 @@ def _light_internal_supported_color_modes(self) -> set[ColorMode] | set[str]: return supported_color_modes - @property + @cached_property def supported_color_modes(self) -> set[ColorMode] | set[str] | None: """Flag supported color modes.""" return self._attr_supported_color_modes - @property + @cached_property def supported_features(self) -> LightEntityFeature: """Flag supported features.""" return self._attr_supported_features + + @property + def supported_features_compat(self) -> LightEntityFeature: + """Return the supported features as LightEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is not int: # noqa: E721 + return features + new_features = LightEntityFeature(features) + if self._deprecated_supported_features_reported is True: + return new_features + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s and color modes, please %s" + ), + self.entity_id, + type(self), + repr(new_features), + report_issue, + ) + return new_features diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index f055f02ebdafa2..54fcd01843ced7 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -17,15 +17,10 @@ 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 @@ 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 @@ 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 @@ class ColorModeAttr(NamedTuple): 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 433da53a57025d..fb7a1539944e0d 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 00000000000000..d168da511e0261 --- /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 00000000000000..6bca49adb4c7f4 --- /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 00000000000000..7b3625c7c67e6f --- /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 00000000000000..5a17d5a39e4ea7 --- /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 00000000000000..3474e9d3acb4fe --- /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 00000000000000..fffcdd7de871c9 --- /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 00000000000000..c7918e21e20756 --- /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 00000000000000..93dd17c5bce7c2 --- /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/litejet/diagnostics.py b/homeassistant/components/litejet/diagnostics.py index b996dcc04139e1..48f38542dfd13b 100644 --- a/homeassistant/components/litejet/diagnostics.py +++ b/homeassistant/components/litejet/diagnostics.py @@ -15,6 +15,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for LiteJet config entry.""" system: LiteJet = hass.data[DOMAIN] return { + "model": system.model_name, "loads": list(system.loads()), "button_switches": list(system.button_switches()), "scenes": list(system.scenes()), diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index 136880257ce268..65dde31436d872 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["pylitejet"], "quality_scale": "platinum", - "requirements": ["pylitejet==0.5.0"] + "requirements": ["pylitejet==0.6.2"] } diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index ce04a53755929e..ec8e4d697fe480 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -51,7 +51,7 @@ def __init__(self, entry_id: str, system: LiteJet, i: int, name: str) -> None: identifiers={(DOMAIN, f"{entry_id}_mcp")}, name="LiteJet", manufacturer="Centralite", - model="CL24", + model=system.model_name, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index 025770cdc354ed..5089b9ec0f96fd 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -49,10 +49,10 @@ def __init__(self, entry_id: str, system: LiteJet, i: int, name: str) -> None: self._attr_name = name # Keypad #1 has switches 1-6, #2 has 7-12, ... - keypad_number = int((i - 1) / 6) + 1 + keypad_number = system.get_switch_keypad_number(i) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{entry_id}_keypad_{keypad_number}")}, - name=f"Keypad #{keypad_number}", + name=system.get_switch_keypad_name(i), manufacturer="Centralite", via_device=(DOMAIN, f"{entry_id}_mcp"), ) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 0872c5c831d82b..6a588c36d6c6dd 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -22,14 +22,14 @@ from .hub import LitterRobotHub -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot binary sensor entity required keys.""" is_on_fn: Callable[[_RobotT], bool] -@dataclass +@dataclass(frozen=True) class RobotBinarySensorEntityDescription( BinarySensorEntityDescription, RequiredKeysMixin[_RobotT] ): diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 06c4fe75888e0e..de93ead5190691 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -46,14 +46,14 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot button entity required keys.""" press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotButtonEntityDescription(ButtonEntityDescription, RequiredKeysMixin[_RobotT]): """A class that describes robot button entities.""" diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 7f2ea62f956f87..726cfaebaeb099 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -28,7 +28,7 @@ } -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): """A class that describes robot select entity required keys.""" @@ -37,7 +37,7 @@ class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotSelectEntityDescription( SelectEntityDescription, RequiredKeysMixin[_RobotT, _CastTypeT] ): diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 935bbaca595e05..a25921e440c2e5 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -35,7 +35,7 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str return "mdi:gauge-low" -@dataclass +@dataclass(frozen=True) class RobotSensorEntityDescription(SensorEntityDescription, Generic[_RobotT]): """A class that describes robot sensor entities.""" diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 6b4e5b56b48ee3..84e6fa2be671f4 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -18,7 +18,7 @@ from .hub import LitterRobotHub -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot switch entity required keys.""" @@ -26,7 +26,7 @@ class RequiredKeysMixin(Generic[_RobotT]): set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotSwitchEntityDescription(SwitchEntityDescription, RequiredKeysMixin[_RobotT]): """A class that describes robot switch entities.""" diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index f352b7cee70ccc..bb840e17a8f463 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -20,7 +20,7 @@ from .hub import LitterRobotHub -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot time entity required keys.""" @@ -28,7 +28,7 @@ class RequiredKeysMixin(Generic[_RobotT]): set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotTimeEntityDescription(TimeEntityDescription, RequiredKeysMixin[_RobotT]): """A class that describes robot time entities.""" diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 4b1a8effb98ab6..a86f1e4be002c8 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -47,7 +47,7 @@ } LITTER_BOX_ENTITY = StateVacuumEntityDescription( - "litter_box", translation_key="litter_box" + key="litter_box", translation_key="litter_box" ) diff --git a/homeassistant/components/livisi/config_flow.py b/homeassistant/components/livisi/config_flow.py index 16cccaacfd113f..c8685eb2390000 100644 --- a/homeassistant/components/livisi/config_flow.py +++ b/homeassistant/components/livisi/config_flow.py @@ -9,10 +9,11 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client -from .const import CONF_HOST, CONF_PASSWORD, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER class LivisiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index f6435298f1e3bc..2769e6030eebd7 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -5,8 +5,6 @@ LOGGER = logging.getLogger(__package__) DOMAIN = "livisi" -CONF_HOST = "host" -CONF_PASSWORD: Final = "password" AVATAR = "Avatar" AVATAR_PORT: Final = 9090 CLASSIC_PORT: Final = 8080 diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 56e928307c1734..17a3b1828d0cbe 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -9,6 +9,7 @@ from aiolivisi.errors import TokenExpiredException from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,8 +18,6 @@ AVATAR, AVATAR_PORT, CLASSIC_PORT, - CONF_HOST, - CONF_PASSWORD, DEVICE_POLLING_DELAY, LIVISI_REACHABILITY_CHANGE, LIVISI_STATE_CHANGE, diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index d7b16ee3bef7ff..f5a24e07b0cd1a 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==6.1.0"] + "requirements": ["ical==6.1.1"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 4c3a8e10a6219c..335a89eab3c4c2 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==6.1.0"] + "requirements": ["ical==6.1.1"] } diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index f9832ad87304cd..99fb6dcebfa971 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -1,13 +1,9 @@ """A Local To-do todo platform.""" -from collections.abc import Iterable -import dataclasses import logging -from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream -from ical.exceptions import CalendarParseError from ical.store import TodoStore from ical.todo import Todo, TodoStatus @@ -59,24 +55,18 @@ async def async_setup_entry( async_add_entities([entity], True) -def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: - """Convert TodoItem dataclass items to dictionary of attributes for ical consumption.""" - result: dict[str, str] = {} - for name, value in obj: - if name == "status": - result[name] = ICS_TODO_STATUS_MAP_INV[value] - elif value is not None: - result[name] = value - return result - - def _convert_item(item: TodoItem) -> Todo: """Convert a HomeAssistant TodoItem to an ical Todo.""" - try: - return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory)) - except CalendarParseError as err: - _LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err) - raise HomeAssistantError("Error parsing todo input fields") from err + todo = Todo() + if item.uid: + todo.uid = item.uid + if item.summary: + todo.summary = item.summary + if item.status: + todo.status = ICS_TODO_STATUS_MAP_INV[item.status] + todo.due = item.due + todo.description = item.description + return todo class LocalTodoListEntity(TodoListEntity): @@ -88,6 +78,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 @@ -113,6 +106,8 @@ async def async_update(self) -> None: 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 8cbce69dc7c1ed..a9370f8d092663 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -1,13 +1,12 @@ """Component to interface with locks that can be controlled remotely.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft import logging import re -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -24,18 +23,28 @@ STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +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, PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.service import remove_entity_service_fields from homeassistant.helpers.typing import ConfigType, StateType +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) ATTR_CHANGED_BY = "changed_by" @@ -59,7 +68,11 @@ class LockEntityFeature(IntFlag): # The SUPPORT_OPEN constant is deprecated as of Home Assistant 2022.5. # Please use the LockEntityFeature enum instead. -SUPPORT_OPEN = 1 +_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(LockEntityFeature.OPEN, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT} @@ -75,54 +88,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) component.async_register_entity_service( - SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, _async_unlock + SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service" ) component.async_register_entity_service( - SERVICE_LOCK, LOCK_SERVICE_SCHEMA, _async_lock + SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service" ) component.async_register_entity_service( - SERVICE_OPEN, LOCK_SERVICE_SCHEMA, _async_open, [LockEntityFeature.OPEN] + SERVICE_OPEN, + LOCK_SERVICE_SCHEMA, + "async_handle_open_service", + [LockEntityFeature.OPEN], ) 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 - ) - 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)) - - -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)) - - -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)) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent[LockEntity] = hass.data[DOMAIN] @@ -135,12 +115,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class LockEntityDescription(EntityDescription): +class LockEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes lock entities.""" -class LockEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "changed_by", + "code_format", + "is_locked", + "is_locking", + "is_unlocking", + "is_jammed", + "supported_features", +} + + +class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for lock entities.""" entity_description: LockEntityDescription @@ -155,12 +145,35 @@ class LockEntity(Entity): _lock_option_default_code: str = "" __code_format_cmp: re.Pattern[str] | None = None - @property + @final + @callback + def add_default_code(self, data: dict[Any, Any]) -> dict[Any, Any]: + """Add default lock code.""" + code: str = data.pop(ATTR_CODE, "") + if not code: + code = self._lock_option_default_code + if self.code_format_cmp and not self.code_format_cmp.match(code): + if TYPE_CHECKING: + assert self.code_format + raise ServiceValidationError( + f"The code for {self.entity_id} doesn't match pattern {self.code_format}", + translation_domain=DOMAIN, + translation_key="add_default_code", + translation_placeholders={ + "entity_id": self.entity_id, + "code_format": self.code_format, + }, + ) + if code: + data[ATTR_CODE] = code + return data + + @cached_property def changed_by(self) -> str | None: """Last change triggered by.""" return self._attr_changed_by - @property + @cached_property def code_format(self) -> str | None: """Regex for code format or None if no code is required.""" return self._attr_code_format @@ -179,26 +192,31 @@ def code_format_cmp(self) -> re.Pattern[str] | None: self.__code_format_cmp = re.compile(self.code_format) return self.__code_format_cmp - @property + @cached_property def is_locked(self) -> bool | None: """Return true if the lock is locked.""" return self._attr_is_locked - @property + @cached_property def is_locking(self) -> bool | None: """Return true if the lock is locking.""" return self._attr_is_locking - @property + @cached_property def is_unlocking(self) -> bool | None: """Return true if the lock is unlocking.""" return self._attr_is_unlocking - @property + @cached_property def is_jammed(self) -> bool | None: """Return true if the lock is jammed (incomplete locking).""" return self._attr_is_jammed + @final + async def async_handle_lock_service(self, **kwargs: Any) -> None: + """Add default code and lock.""" + await self.async_lock(**self.add_default_code(kwargs)) + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" raise NotImplementedError() @@ -207,6 +225,11 @@ async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.lock, **kwargs)) + @final + async def async_handle_unlock_service(self, **kwargs: Any) -> None: + """Add default code and unlock.""" + await self.async_unlock(**self.add_default_code(kwargs)) + def unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" raise NotImplementedError() @@ -215,6 +238,11 @@ async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.unlock, **kwargs)) + @final + async def async_handle_open_service(self, **kwargs: Any) -> None: + """Add default code and open.""" + await self.async_open(**self.add_default_code(kwargs)) + def open(self, **kwargs: Any) -> None: """Open the door latch.""" raise NotImplementedError() @@ -247,10 +275,15 @@ def state(self) -> str | None: return None return STATE_LOCKED if locked else STATE_UNLOCKED - @property + @cached_property def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = LockEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index d041d6ac61aa01..152a06f9e532ec 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -66,5 +66,10 @@ } } } + }, + "exceptions": { + "add_default_code": { + "message": "The code for {entity_id} doesn't match pattern {code_format}." + } } } diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json index aad9c122d2377e..27ad49b0e3a451 100644 --- a/homeassistant/components/logbook/strings.json +++ b/homeassistant/components/logbook/strings.json @@ -1,4 +1,5 @@ { + "title": "Logbook", "services": { "log": { "name": "Log", diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 7656de8d38567b..37156e9ca08baa 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 2c425bec785141..daa44bf60be090 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -4,7 +4,10 @@ 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 reload_resources_service_handler(service_call: ServiceCall) -> None: 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/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index e16414512210df..d935ad9bff58b9 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -14,7 +14,7 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, storage -from homeassistant.util.yaml import Secrets, load_yaml +from homeassistant.util.yaml import Secrets, load_yaml_dict from .const import ( CONF_ICON, @@ -201,7 +201,9 @@ def _load_config(self, force): is_updated = self._cache is not None try: - config = load_yaml(self.path, Secrets(Path(self.hass.config.config_dir))) + config = load_yaml_dict( + self.path, Secrets(Path(self.hass.config.config_dir)) + ) except FileNotFoundError: raise ConfigNotFound from None diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index 2c6e7b2fff83cb..ee369baf8dd0d4 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/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index 6fa6c55de2e431..e73feef55a1ead 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lupusec", "iot_class": "local_polling", "loggers": ["lupupy"], - "requirements": ["lupupy==0.3.0"] + "requirements": ["lupupy==0.3.1"] } diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 981a2a8633a0c0..37a3b2ec969449 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -28,7 +28,7 @@ def setup_platform( data = hass.data[LUPUSEC_DOMAIN] - device_types = [CONST.TYPE_SWITCH] + device_types = CONST.TYPE_SWITCH devices = [] for device in data.lupusec.get_devices(generic_type=device_types): diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 41369046d51986..0788af76aca2f2 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -322,7 +322,7 @@ def _async_setup_keypads( @callback def _async_build_trigger_schemas( - keypad_button_names_to_leap: dict[int, dict[str, int]] + keypad_button_names_to_leap: dict[int, dict[str, int]], ) -> dict[int, vol.Schema]: """Build device trigger schemas.""" diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index b5ec175d1c9c2a..0fb906f097f496 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 d0bad55ff14cf6..e2504232c689a9 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import enum import logging from time import localtime, strftime, time from typing import Any @@ -151,6 +152,13 @@ async def async_setup_entry( ) +class LyricThermostatType(enum.Enum): + """Lyric thermostats are classified as TCC or LCC devices.""" + + TCC = enum.auto() + LCC = enum.auto() + + class LyricClimate(LyricDeviceEntity, ClimateEntity): """Defines a Honeywell Lyric climate entity.""" @@ -201,8 +209,10 @@ def __init__( # Setup supported features if device.changeableValues.thermostatSetpointStatus: self._attr_supported_features = SUPPORT_FLAGS_LCC + self._attr_thermostat_type = LyricThermostatType.LCC else: self._attr_supported_features = SUPPORT_FLAGS_TCC + self._attr_thermostat_type = LyricThermostatType.TCC # Setup supported fan modes if device_fan_modes := device.settings.attributes.get("fan", {}).get( @@ -324,6 +334,15 @@ async def async_set_temperature(self, **kwargs: Any) -> None: "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 +350,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: 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) @@ -356,56 +375,69 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" _LOGGER.debug("HVAC mode: %s", hvac_mode) try: - if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL: - # If the system is off, turn it to Heat first then to Auto, - # otherwise it turns to. - # Auto briefly and then reverts to Off (perhaps related to - # heatCoolMode). This is the behavior that happens with the - # native app as well, so likely a bug in the api itself - if HVAC_MODES[self.device.changeableValues.mode] == HVACMode.OFF: - _LOGGER.debug( - "HVAC mode passed to lyric: %s", - HVAC_MODES[LYRIC_HVAC_MODE_COOL], - ) - await self._update_thermostat( - self.location, - self.device, - mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - autoChangeoverActive=False, - ) - # Sleep 3 seconds before proceeding - await asyncio.sleep(3) - _LOGGER.debug( - "HVAC mode passed to lyric: %s", - HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - ) - await self._update_thermostat( - self.location, - self.device, - mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - autoChangeoverActive=True, - ) - else: - _LOGGER.debug( - "HVAC mode passed to lyric: %s", - HVAC_MODES[self.device.changeableValues.mode], - ) - await self._update_thermostat( - self.location, self.device, autoChangeoverActive=True - ) - else: + match self._attr_thermostat_type: + case LyricThermostatType.TCC: + await self._async_set_hvac_mode_tcc(hvac_mode) + case LyricThermostatType.LCC: + await self._async_set_hvac_mode_lcc(hvac_mode) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() + + async def _async_set_hvac_mode_tcc(self, hvac_mode: HVACMode) -> None: + if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL: + # If the system is off, turn it to Heat first then to Auto, + # otherwise it turns to. + # Auto briefly and then reverts to Off (perhaps related to + # heatCoolMode). This is the behavior that happens with the + # native app as well, so likely a bug in the api itself + if HVAC_MODES[self.device.changeableValues.mode] == HVACMode.OFF: _LOGGER.debug( - "HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode] + "HVAC mode passed to lyric: %s", + HVAC_MODES[LYRIC_HVAC_MODE_COOL], ) await self._update_thermostat( self.location, self.device, - mode=LYRIC_HVAC_MODES[hvac_mode], + mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], autoChangeoverActive=False, ) - except LYRIC_EXCEPTIONS as exception: - _LOGGER.error(exception) - await self.coordinator.async_refresh() + # Sleep 3 seconds before proceeding + await asyncio.sleep(3) + _LOGGER.debug( + "HVAC mode passed to lyric: %s", + HVAC_MODES[LYRIC_HVAC_MODE_HEAT], + ) + await self._update_thermostat( + self.location, + self.device, + mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], + autoChangeoverActive=True, + ) + else: + _LOGGER.debug( + "HVAC mode passed to lyric: %s", + HVAC_MODES[self.device.changeableValues.mode], + ) + await self._update_thermostat( + self.location, self.device, autoChangeoverActive=True + ) + else: + _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]) + await self._update_thermostat( + self.location, + self.device, + mode=LYRIC_HVAC_MODES[hvac_mode], + autoChangeoverActive=False, + ) + + async def _async_set_hvac_mode_lcc(self, hvac_mode: HVACMode) -> None: + _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]) + await self._update_thermostat( + self.location, + self.device, + mode=LYRIC_HVAC_MODES[hvac_mode], + ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset (PermanentHold, HoldUntil, NoHold, VacationHold) mode.""" diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index f0a4cdfbb99e58..1b9af351e71ee3 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -41,7 +41,7 @@ } -@dataclass +@dataclass(frozen=True) class LyricSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -49,7 +49,7 @@ class LyricSensorEntityDescriptionMixin: suitable_fn: Callable[[LyricDevice], bool] -@dataclass +@dataclass(frozen=True) class LyricSensorEntityDescription( SensorEntityDescription, LyricSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 6b594654dfa398..68bb6292f9ee19 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -15,8 +15,8 @@ "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]" + "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/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 679abfd3164094..623d0f062953e9 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -13,13 +13,10 @@ from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView +from homeassistant.config import config_per_platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - discovery, -) +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index f9ef3593fe61d1..44a65a2de59d95 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -7,7 +7,7 @@ import mimetypes import os import re -from typing import NewType, TypedDict +from typing import Final, NewType, Required, TypedDict import aiofiles.os from nio import AsyncClient, Event, MatrixRoom @@ -49,11 +49,11 @@ SESSION_FILE = ".matrix.conf" -CONF_HOMESERVER = "homeserver" -CONF_ROOMS = "rooms" -CONF_COMMANDS = "commands" -CONF_WORD = "word" -CONF_EXPRESSION = "expression" +CONF_HOMESERVER: Final = "homeserver" +CONF_ROOMS: Final = "rooms" +CONF_COMMANDS: Final = "commands" +CONF_WORD: Final = "word" +CONF_EXPRESSION: Final = "expression" CONF_USERNAME_REGEX = "^@[^:]*:.*" CONF_ROOMS_REGEX = "^[!|#][^:]*:.*" @@ -78,10 +78,10 @@ class ConfigCommand(TypedDict, total=False): """Corresponds to a single COMMAND_SCHEMA.""" - name: str # CONF_NAME - rooms: list[RoomID] | None # CONF_ROOMS - word: WordCommand | None # CONF_WORD - expression: ExpressionCommand | None # CONF_EXPRESSION + name: Required[str] # CONF_NAME + rooms: list[RoomID] # CONF_ROOMS + word: WordCommand # CONF_WORD + expression: ExpressionCommand # CONF_EXPRESSION COMMAND_SCHEMA = vol.All( @@ -223,15 +223,15 @@ async def handle_startup(event: HassEvent) -> None: def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: # Set the command for all listening_rooms, unless otherwise specified. - command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # type: ignore[misc] + command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. if (word_command := command.get(CONF_WORD)) is not None: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._word_commands.setdefault(room_id, {}) - self._word_commands[room_id][word_command] = command # type: ignore[index] + self._word_commands[room_id][word_command] = command else: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._expression_commands.setdefault(room_id, []) self._expression_commands[room_id].append(command) @@ -263,7 +263,7 @@ async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None: # After single-word commands, check all regex commands in the room. for command in self._expression_commands.get(room_id, []): - match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] + match = command[CONF_EXPRESSION].match(message.body) if not match: continue message_data = { @@ -348,7 +348,10 @@ async def _store_auth_token(self, token: str) -> None: 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/adapter.py b/homeassistant/components/matter/adapter.py index 52b8e905b4b6ff..5690996841d162 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -97,22 +97,23 @@ def node_removed_callback(event: EventType, node_id: int) -> None: 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 ) ) @@ -145,9 +146,7 @@ def _create_device_registry( get_clean_name(basic_info.nodeLabel) or get_clean_name(basic_info.productLabel) or get_clean_name(basic_info.productName) - or device_type.__name__ - if device_type - else None + or (device_type.__name__ if device_type else None) ) # handle bridged devices diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index 7b4b7d35b7f062..2df21d8f7a2b1f 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -1,9 +1,9 @@ """Handle websocket api for Matter.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from functools import wraps -from typing import Any +from typing import Any, Concatenate, ParamSpec from matter_server.common.errors import MatterError import voluptuous as vol @@ -15,6 +15,8 @@ from .adapter import MatterAdapter from .helpers import get_matter +_P = ParamSpec("_P") + ID = "id" TYPE = "type" @@ -28,12 +30,19 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_set_wifi_credentials) -def async_get_matter_adapter(func: Callable) -> Callable: +def async_get_matter_adapter( + func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], MatterAdapter], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate function to get the MatterAdapter.""" @wraps(func) async def _get_matter( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide the Matter client to the function.""" matter = get_matter(hass) @@ -43,7 +52,15 @@ async def _get_matter( return _get_matter -def async_handle_failed_command(func: Callable) -> Callable: +def async_handle_failed_command( + func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate function to handle MatterError and send relevant error.""" @wraps(func) @@ -51,8 +68,8 @@ async def async_handle_failed_command_func( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - *args: Any, - **kwargs: Any, + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle MatterError within function and send relevant error.""" try: @@ -68,6 +85,7 @@ async def async_handle_failed_command_func( { vol.Required(TYPE): "matter/commission", vol.Required("code"): str, + vol.Optional("network_only"): bool, } ) @websocket_api.async_response @@ -80,7 +98,9 @@ async def websocket_commission( matter: MatterAdapter, ) -> None: """Add a device to the network and commission the device.""" - await matter.matter_client.commission_with_code(msg["code"]) + await matter.matter_client.commission_with_code( + msg["code"], network_only=msg.get("network_only", True) + ) connection.send_result(msg[ID]) @@ -89,6 +109,7 @@ async def websocket_commission( { vol.Required(TYPE): "matter/commission_on_network", vol.Required("pin"): int, + vol.Optional("ip_addr"): str, } ) @websocket_api.async_response @@ -101,7 +122,9 @@ async def websocket_commission_on_network( matter: MatterAdapter, ) -> None: """Commission a device already on the network.""" - await matter.matter_client.commission_on_network(msg["pin"]) + await matter.matter_client.commission_on_network( + msg["pin"], ip_addr=msg.get("ip_addr", None) + ) connection.send_result(msg[ID]) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index aabfc12eefbf6a..ea87fabf3f5704 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -32,7 +32,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) -@dataclass +@dataclass(frozen=True) class MatterBinarySensorEntityDescription( BinarySensorEntityDescription, MatterEntityDescription ): diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index c971bf8465ed13..e1d004a15c83ae 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 102e0c83b7b0ef..e308699acad017 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -3,7 +3,9 @@ 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 @@ -11,9 +13,10 @@ 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 @@ -26,8 +29,15 @@ 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 + +@dataclass(frozen=True) class MatterEntityDescription(EntityDescription): """Describe the Matter entity.""" @@ -38,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__( @@ -70,6 +79,8 @@ def __init__( 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.""" @@ -109,13 +120,35 @@ async def async_added_to_hass(self) -> None: 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: - unsub() + 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() @@ -142,3 +175,9 @@ def get_matter_attribute_path( 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 3361c3fa146fa5..e84fcec32d8f82 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -104,9 +104,11 @@ def _update_from_device(self) -> None: """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 dcd6a30ee1fdf8..446d5dc3591b7f 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -94,7 +94,7 @@ 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/lock.py b/homeassistant/components/matter/lock.py index 8491f58e38798d..dd29638f765ecb 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -89,10 +89,7 @@ async def send_device_command( async def async_lock(self, **kwargs: Any) -> None: """Lock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.LockDoor(code_bytes) @@ -100,10 +97,7 @@ async def async_lock(self, **kwargs: Any) -> None: async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None if self.supports_unbolt: # if the lock reports it has separate unbolt support, @@ -119,10 +113,7 @@ async def async_unlock(self, **kwargs: Any) -> None: async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 6f494153a97c4b..848e89660ed317 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.0"] + "requirements": ["python-matter-server==5.1.1"] } diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 34447751797f6d..5f47f73b139158 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -50,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.""" @@ -106,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 5021ed7fa0d016..e7b18f308f7492 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -5,6 +5,7 @@ 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 @@ PERCENTAGE, EntityCategory, Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, UnitOfPressure, UnitOfTemperature, UnitOfVolumeFlowRate, @@ -40,7 +45,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SENSOR, async_add_entities) -@dataclass +@dataclass(frozen=True) class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescription): """Describe Matter sensor entities.""" @@ -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 @@ def _update_from_device(self) -> None: 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 @@ def _update_from_device(self) -> None: 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 @@ def _update_from_device(self) -> None: 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 @@ def _update_from_device(self) -> None: 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 @@ def _update_from_device(self) -> None: 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 @@ def _update_from_device(self) -> None: 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/matter/switch.py b/homeassistant/components/matter/switch.py index e1fb4464b83122..61922e8e8c9844 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -67,7 +67,15 @@ def _update_from_device(self) -> None: ), entity_class=MatterSwitch, required_attributes=(clusters.OnOff.Attributes.OnOff,), - # restrict device type to prevent discovery by the wrong platform + device_type=(device_types.OnOffPlugInUnit,), + ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=SwitchEntityDescription( + key="MatterSwitch", device_class=SwitchDeviceClass.SWITCH, name=None + ), + entity_class=MatterSwitch, + required_attributes=(clusters.OnOff.Attributes.OnOff,), not_device_type=( device_types.ColorTemperatureLight, device_types.DimmableLight, @@ -76,7 +84,6 @@ def _update_from_device(self) -> None: device_types.DoorLock, device_types.ColorDimmerSwitch, device_types.DimmerSwitch, - device_types.OnOffLightSwitch, device_types.Thermostat, ), ), diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 98bb44947c8a45..a7e03ae7c22d57 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -27,7 +27,7 @@ from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class MeaterSensorEntityDescriptionMixin: """Mixin for MeaterSensorEntityDescription.""" @@ -35,7 +35,7 @@ class MeaterSensorEntityDescriptionMixin: value: Callable[[MeaterProbe], datetime | float | str | None] -@dataclass +@dataclass(frozen=True) class MeaterSensorEntityDescription( SensorEntityDescription, MeaterSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 39ce1f7a3bd803..b657caceaff581 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -1,6 +1,7 @@ """Decorator service for the media_player.play_media service.""" from collections.abc import Callable import logging +from pathlib import Path from typing import Any, cast import voluptuous as vol @@ -106,7 +107,20 @@ def extract_and_send(self) -> None: def get_stream_selector(self) -> Callable[[str], str]: """Return format selector for the media URL.""" - ydl = YoutubeDL({"quiet": True, "logger": _LOGGER}) + cookies_file = Path( + self.hass.config.config_dir, "media_extractor", "cookies.txt" + ) + ydl_params = {"quiet": True, "logger": _LOGGER} + if cookies_file.exists(): + ydl_params["cookiefile"] = str(cookies_file) + _LOGGER.debug( + "Media extractor loaded cookies file from: %s", str(cookies_file) + ) + else: + _LOGGER.debug( + "Media extractor didn't find cookies file at: %s", str(cookies_file) + ) + ydl = YoutubeDL(ydl_params) try: all_media = ydl.extract_info(self.get_media_url(), process=False) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index d16439800a9311..111509c1f31e6b 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 f3ff925a1a4699..113048421e11bd 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -5,15 +5,15 @@ import collections from collections.abc import Callable from contextlib import suppress -from dataclasses import dataclass import datetime as dt from enum import StrEnum import functools as ft +from functools import lru_cache import hashlib from http import HTTPStatus import logging import secrets -from typing import Any, Final, Required, TypedDict, final +from typing import TYPE_CHECKING, Any, Final, Required, TypedDict, final from urllib.parse import quote, urlparse from aiohttp import web @@ -132,6 +132,11 @@ ) from .errors import BrowseError +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -449,14 +454,56 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class MediaPlayerEntityDescription(EntityDescription): +class MediaPlayerEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes media player entities.""" device_class: MediaPlayerDeviceClass | None = None + volume_step: float | None = None + + +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "state", + "volume_level", + "volume_step", + "is_volume_muted", + "media_content_id", + "media_content_type", + "media_duration", + "media_position", + "media_position_updated_at", + "media_image_url", + "media_image_remotely_accessible", + "media_title", + "media_artist", + "media_album_name", + "media_album_artist", + "media_track", + "media_series_title", + "media_season", + "media_episode", + "media_channel", + "media_playlist", + "app_id", + "app_name", + "source", + "source_list", + "sound_mode", + "sound_mode_list", + "shuffle", + "repeat", + "group_members", + "supported_features", +} + + +@lru_cache +def _url_hash(url: str) -> str: + """Create hash for media image url.""" + return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] -class MediaPlayerEntity(Entity): +class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ABC for media player entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -505,9 +552,10 @@ class MediaPlayerEntity(Entity): _attr_state: MediaPlayerState | None = None _attr_supported_features: MediaPlayerEntityFeature = MediaPlayerEntityFeature(0) _attr_volume_level: float | None = None + _attr_volume_step: float # Implement these for your media player - @property + @cached_property def device_class(self) -> MediaPlayerDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -516,7 +564,7 @@ def device_class(self) -> MediaPlayerDeviceClass | None: return self.entity_description.device_class return None - @property + @cached_property def state(self) -> MediaPlayerState | None: """State of the player.""" return self._attr_state @@ -528,37 +576,49 @@ def access_token(self) -> str: self._access_token = secrets.token_hex(32) return self._access_token - @property + @cached_property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" return self._attr_volume_level - @property + @cached_property + def volume_step(self) -> float: + """Return the step to be used by the volume_up and volume_down services.""" + if hasattr(self, "_attr_volume_step"): + return self._attr_volume_step + if ( + hasattr(self, "entity_description") + and (volume_step := self.entity_description.volume_step) is not None + ): + return volume_step + return 0.1 + + @cached_property def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" return self._attr_is_volume_muted - @property + @cached_property def media_content_id(self) -> str | None: """Content ID of current playing media.""" return self._attr_media_content_id - @property + @cached_property def media_content_type(self) -> MediaType | str | None: """Content type of current playing media.""" return self._attr_media_content_type - @property + @cached_property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" return self._attr_media_duration - @property + @cached_property def media_position(self) -> int | None: """Position of current playing media in seconds.""" return self._attr_media_position - @property + @cached_property def media_position_updated_at(self) -> dt.datetime | None: """When was the position of the current playing media valid. @@ -566,12 +626,12 @@ def media_position_updated_at(self) -> dt.datetime | None: """ return self._attr_media_position_updated_at - @property + @cached_property def media_image_url(self) -> str | None: """Image url of current playing media.""" return self._attr_media_image_url - @property + @cached_property def media_image_remotely_accessible(self) -> bool: """If the image url is remotely accessible.""" return self._attr_media_image_remotely_accessible @@ -583,7 +643,7 @@ def media_image_hash(self) -> str | None: return self._attr_media_image_hash if (url := self.media_image_url) is not None: - return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] + return _url_hash(url) return None @@ -606,106 +666,119 @@ async def async_get_browse_image( """ return None, None - @property + @cached_property def media_title(self) -> str | None: """Title of current playing media.""" return self._attr_media_title - @property + @cached_property def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" return self._attr_media_artist - @property + @cached_property def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" return self._attr_media_album_name - @property + @cached_property def media_album_artist(self) -> str | None: """Album artist of current playing media, music track only.""" return self._attr_media_album_artist - @property + @cached_property def media_track(self) -> int | None: """Track number of current playing media, music track only.""" return self._attr_media_track - @property + @cached_property def media_series_title(self) -> str | None: """Title of series of current playing media, TV show only.""" return self._attr_media_series_title - @property + @cached_property def media_season(self) -> str | None: """Season of current playing media, TV show only.""" return self._attr_media_season - @property + @cached_property def media_episode(self) -> str | None: """Episode of current playing media, TV show only.""" return self._attr_media_episode - @property + @cached_property def media_channel(self) -> str | None: """Channel currently playing.""" return self._attr_media_channel - @property + @cached_property def media_playlist(self) -> str | None: """Title of Playlist currently playing.""" return self._attr_media_playlist - @property + @cached_property def app_id(self) -> str | None: """ID of the current running app.""" return self._attr_app_id - @property + @cached_property def app_name(self) -> str | None: """Name of the current running app.""" return self._attr_app_name - @property + @cached_property def source(self) -> str | None: """Name of the current input source.""" return self._attr_source - @property + @cached_property def source_list(self) -> list[str] | None: """List of available input sources.""" return self._attr_source_list - @property + @cached_property def sound_mode(self) -> str | None: """Name of the current sound mode.""" return self._attr_sound_mode - @property + @cached_property def sound_mode_list(self) -> list[str] | None: """List of available sound modes.""" return self._attr_sound_mode_list - @property + @cached_property def shuffle(self) -> bool | None: """Boolean if shuffle is enabled.""" return self._attr_shuffle - @property + @cached_property def repeat(self) -> RepeatMode | str | None: """Return current repeat mode.""" return self._attr_repeat - @property + @cached_property def group_members(self) -> list[str] | None: """List of members which are currently grouped together.""" return self._attr_group_members - @property + @cached_property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> MediaPlayerEntityFeature: + """Return the supported features as MediaPlayerEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = MediaPlayerEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError() @@ -845,87 +918,87 @@ async def async_set_repeat(self, repeat: RepeatMode) -> None: @property def support_play(self) -> bool: """Boolean if play is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PLAY) + return MediaPlayerEntityFeature.PLAY in self.supported_features_compat @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PAUSE) + return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.STOP) + return MediaPlayerEntityFeature.STOP in self.supported_features_compat @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.SEEK) + return MediaPlayerEntityFeature.SEEK in self.supported_features_compat @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.VOLUME_SET) + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.VOLUME_MUTE) + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PREVIOUS_TRACK) + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.NEXT_TRACK) + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PLAY_MEDIA) + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE) + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return bool( - self.supported_features & MediaPlayerEntityFeature.SELECT_SOUND_MODE + return ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat ) @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.CLEAR_PLAYLIST) + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.SHUFFLE_SET) + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.GROUPING) + return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -954,9 +1027,11 @@ async def async_volume_up(self) -> None: if ( self.volume_level is not None and self.volume_level < 1 - and self.supported_features & MediaPlayerEntityFeature.VOLUME_SET + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): - await self.async_set_volume_level(min(1, self.volume_level + 0.1)) + await self.async_set_volume_level( + min(1, self.volume_level + self.volume_step) + ) async def async_volume_down(self) -> None: """Turn volume down for media player. @@ -970,9 +1045,11 @@ async def async_volume_down(self) -> None: if ( self.volume_level is not None and self.volume_level > 0 - and self.supported_features & MediaPlayerEntityFeature.VOLUME_SET + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): - await self.async_set_volume_level(max(0, self.volume_level - 0.1)) + await self.async_set_volume_level( + max(0, self.volume_level - self.volume_step) + ) async def async_media_play_pause(self) -> None: """Play or pause the media player.""" @@ -1011,16 +1088,16 @@ def media_image_local(self) -> str | None: def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat - if supported_features & MediaPlayerEntityFeature.SELECT_SOURCE and ( + if ( source_list := self.source_list - ): + ) and MediaPlayerEntityFeature.SELECT_SOURCE in supported_features: data[ATTR_INPUT_SOURCE_LIST] = source_list - if supported_features & MediaPlayerEntityFeature.SELECT_SOUND_MODE and ( + if ( sound_mode_list := self.sound_mode_list - ): + ) and MediaPlayerEntityFeature.SELECT_SOUND_MODE in supported_features: data[ATTR_SOUND_MODE_LIST] = sound_mode_list return data @@ -1137,8 +1214,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: @@ -1219,7 +1295,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if not player.supported_features & MediaPlayerEntityFeature.BROWSE_MEDIA: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py new file mode 100644 index 00000000000000..43a253d9220be6 --- /dev/null +++ b/homeassistant/components/media_player/significant_change.py @@ -0,0 +1,70 @@ +"""Helper to test significant Media Player state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ( + ATTR_ENTITY_PICTURE_LOCAL, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_TO_PROPERTY, +) + +INSIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, +} + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_ENTITY_PICTURE_LOCAL, + *ATTR_TO_PROPERTY, +} - INSIGNIFICANT_ATTRIBUTES + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name != ATTR_MEDIA_VOLUME_LEVEL: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 0.1): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 5f007f3a8e5124..d1ed5cafcbf8d1 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -6,7 +6,7 @@ import logging from typing import Any -from aiohttp import ClientConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from pymelcloud import Device, get_devices from pymelcloud.atw_device import Zone import voluptuous as vol @@ -14,7 +14,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -66,7 +66,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with MELClooud.""" conf = entry.data - mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + try: + mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + except ClientResponseError as ex: + if isinstance(ex, ClientResponseError) and ex.code == 401: + raise ConfigEntryAuthFailed from ex + raise ConfigEntryNotReady from ex + except (asyncio.TimeoutError, ClientConnectionError) as ex: + raise ConfigEntryNotReady from ex + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -162,17 +170,13 @@ async def mel_devices_setup( ) -> dict[str, list[MelCloudDevice]]: """Query connected devices from MELCloud.""" session = async_get_clientsession(hass) - try: - async with asyncio.timeout(10): - all_devices = await get_devices( - token, - session, - conf_update_interval=timedelta(minutes=5), - device_set_debounce=timedelta(seconds=1), - ) - except (asyncio.TimeoutError, ClientConnectionError) as ex: - raise ConfigEntryNotReady() from ex - + async with asyncio.timeout(10): + all_devices = await get_devices( + token, + session, + conf_update_interval=timedelta(minutes=5), + device_set_debounce=timedelta(seconds=1), + ) wrapped_devices: dict[str, list[MelCloudDevice]] = {} for device_type, devices in all_devices.items(): wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 0ff17ea751a323..9293c9bb3d5b6e 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -2,7 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from http import HTTPStatus +import logging +from typing import Any from aiohttp import ClientError, ClientResponseError import pymelcloud @@ -11,12 +14,14 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import AbortFlow, FlowResultType +from homeassistant.data_entry_flow import AbortFlow, FlowResult, FlowResultType from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + async def async_create_import_issue( hass: HomeAssistant, source: str, issue: str, success: bool = False @@ -56,7 +61,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _create_entry(self, username: str, token: str): + entry: config_entries.ConfigEntry | None = None + + async def _create_entry(self, username: str, token: str) -> FlowResult: """Register new entry.""" await self.async_set_unique_id(username) try: @@ -74,7 +81,7 @@ async def _create_client( *, password: str | None = None, token: str | None = None, - ): + ) -> FlowResult: """Create client.""" try: async with asyncio.timeout(10): @@ -106,7 +113,9 @@ async def _create_client( return await self._create_entry(username, acquired_token) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """User initiated config flow.""" if user_input is None: return self.async_show_form( @@ -118,7 +127,7 @@ async def async_step_user(self, user_input=None): username = user_input[CONF_USERNAME] return await self._create_client(username, password=user_input[CONF_PASSWORD]) - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import a config entry.""" result = await self._create_client( user_input[CONF_USERNAME], token=user_input[CONF_TOKEN] @@ -126,3 +135,67 @@ async def async_step_import(self, user_input): if result["type"] == FlowResultType.CREATE_ENTRY: await async_create_import_issue(self.hass, self.context["source"], "", True) return result + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with MELCloud.""" + 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: + """Handle re-authentication with MELCloud.""" + errors: dict[str, str] = {} + + if user_input is not None and self.entry: + aquired_token, errors = await self.async_reauthenticate_client(user_input) + + if not errors: + self.hass.config_entries.async_update_entry( + self.entry, + data={CONF_TOKEN: aquired_token}, + ) + self.hass.async_create_task( + 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_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + async def async_reauthenticate_client( + self, user_input: dict[str, Any] + ) -> tuple[str | None, dict[str, str]]: + """Reauthenticate with MELCloud.""" + errors: dict[str, str] = {} + acquired_token = None + + try: + async with asyncio.timeout(10): + acquired_token = await pymelcloud.login( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + async_get_clientsession(self.hass), + ) + except (ClientResponseError, AttributeError) as err: + if isinstance(err, ClientResponseError) and err.status in ( + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + errors["base"] = "invalid_auth" + elif isinstance(err, AttributeError) and err.name == "get": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except ( + asyncio.TimeoutError, + ClientError, + ): + errors["base"] = "cannot_connect" + + return acquired_token, errors diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index ca02d15db01c74..cf53fe42b771a8 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +import dataclasses from typing import Any from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW @@ -23,7 +23,7 @@ from .const import DOMAIN -@dataclass +@dataclasses.dataclass(frozen=True) class MelcloudRequiredKeysMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class MelcloudRequiredKeysMixin: enabled: Callable[[Any], bool] -@dataclass +@dataclasses.dataclass(frozen=True) class MelcloudSensorEntityDescription( SensorEntityDescription, MelcloudRequiredKeysMixin ): @@ -203,7 +203,10 @@ def __init__( ) -> None: """Initialize the sensor.""" if zone.zone_index != 1: - description.key = f"{description.key}-zone-{zone.zone_index}" + description = dataclasses.replace( + description, + key=f"{description.key}-zone-{zone.zone_index}", + ) super().__init__(api, description) self._attr_device_info = api.zone_device_info(zone) diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index eefd5a07a8d28e..3abb30bf9ac5ef 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -8,6 +8,14 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Melcloud integration needs to re-authenticate your connection details", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -16,6 +24,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." } }, diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index e0f9c7d3bf67a8..caf2d4998518ce 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -26,7 +26,7 @@ ) -@dataclass +@dataclass(frozen=True) class MelnorZoneNumberEntityDescriptionMixin: """Mixin for required keys.""" @@ -34,7 +34,7 @@ class MelnorZoneNumberEntityDescriptionMixin: state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneNumberEntityDescription( NumberEntityDescription, MelnorZoneNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index edb906cc80f41e..255c3c9747dcb3 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -54,28 +54,28 @@ def next_cycle(valve: Valve) -> datetime | None: return None -@dataclass +@dataclass(frozen=True) class MelnorSensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Device], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneSensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneSensorEntityDescription( SensorEntityDescription, MelnorZoneSensorEntityDescriptionMixin ): """Describes Melnor sensor entity.""" -@dataclass +@dataclass(frozen=True) class MelnorSensorEntityDescription( SensorEntityDescription, MelnorSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index 03bd28faa9d98e..e3c0e0afa15b25 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -25,7 +25,7 @@ ) -@dataclass +@dataclass(frozen=True) class MelnorSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -33,7 +33,7 @@ class MelnorSwitchEntityDescriptionMixin: state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorSwitchEntityDescription( SwitchEntityDescription, MelnorSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py index 943a7996aeb250..36afe2d976d960 100644 --- a/homeassistant/components/melnor/time.py +++ b/homeassistant/components/melnor/time.py @@ -23,7 +23,7 @@ ) -@dataclass +@dataclass(frozen=True) class MelnorZoneTimeEntityDescriptionMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class MelnorZoneTimeEntityDescriptionMixin: state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneTimeEntityDescription( TimeEntityDescription, MelnorZoneTimeEntityDescriptionMixin ): diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 3b6bb9c3518ec3..567788ec479c28 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteo_france", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], - "requirements": ["meteofrance-api==1.2.0"] + "requirements": ["meteofrance-api==1.3.0"] } diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index dd8fd4af83b0c2..451d617e65b469 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -51,14 +51,14 @@ _DataT = TypeVar("_DataT", bound=Rain | Forecast | CurrentPhenomenons) -@dataclass +@dataclass(frozen=True) class MeteoFranceRequiredKeysMixin: """Mixin for required keys.""" data_path: str -@dataclass +@dataclass(frozen=True) class MeteoFranceSensorEntityDescription( SensorEntityDescription, MeteoFranceRequiredKeysMixin ): diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 4354b9b06bda55..8407dd14a6e873 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -25,9 +25,11 @@ 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 @@ 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 9e0a610c7701b6..d03e46a1d0b528 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,10 +31,12 @@ IDENTITY, INFO, IS_CAPSMAN, + IS_WIFI, IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFI, WIFIWAVE2, WIRELESS, ) @@ -60,6 +62,7 @@ def __init__( 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 = "" @@ -88,7 +91,7 @@ def force_dhcp(self) -> bool: def get_info(self, param: str) -> str: """Return device model name.""" cmd = IDENTITY if param == NAME else INFO - if data := self.command(MIKROTIK_SERVICES[cmd]): + if data := self.command(MIKROTIK_SERVICES[cmd], suppress_errors=(cmd == INFO)): return str(data[0].get(param)) return "" @@ -98,9 +101,18 @@ def get_hub_details(self) -> None: self.model = self.get_info(ATTR_MODEL) self.firmware = self.get_info(ATTR_FIRMWARE) self.serial_number = self.get_info(ATTR_SERIAL_NUMBER) - 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_capsman = bool( + self.command(MIKROTIK_SERVICES[IS_CAPSMAN], suppress_errors=True) + ) + self.support_wireless = bool( + self.command(MIKROTIK_SERVICES[IS_WIRELESS], suppress_errors=True) + ) + self.support_wifiwave2 = bool( + self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2], suppress_errors=True) + ) + self.support_wifi = bool( + self.command(MIKROTIK_SERVICES[IS_WIFI], suppress_errors=True) + ) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -128,6 +140,9 @@ def update_devices(self) -> None: 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 @@ -198,7 +213,10 @@ def do_arp_ping(self, ip_address: str, interface: str) -> bool: return True def command( - self, cmd: str, params: dict[str, Any] | None = None + self, + cmd: str, + params: dict[str, Any] | None = None, + suppress_errors: bool = False, ) -> list[dict[str, Any]]: """Retrieve data from Mikrotik API.""" _LOGGER.debug("Running command %s", cmd) @@ -217,12 +235,11 @@ def command( # we still have to raise CannotConnect to fail the update. raise CannotConnect from api_error except librouteros.exceptions.ProtocolError as api_error: - _LOGGER.warning( - "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", - self._host, - cmd, - api_error, - ) + emsg = "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s" + if suppress_errors and "no such command prefix" in str(api_error): + _LOGGER.debug(emsg, self._host, cmd, api_error) + return [] + _LOGGER.warning(emsg, self._host, cmd, api_error) return [] diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index cb0ba4522bf34e..16e7bf552baa4f 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.8", "mill-local==0.3.0"] } diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 4e5ab9290f0b00..0e2debda33ec80 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -30,13 +30,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" - # Check and create API instance. + # Create API instance. + api = MinecraftServer( + hass, + entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + entry.data[CONF_ADDRESS], + ) + + # Initialize API instance. try: - api = await hass.async_add_executor_job( - MinecraftServer, - entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), - entry.data[CONF_ADDRESS], - ) + await api.async_initialize() except MinecraftServerAddressError as error: raise ConfigEntryError( f"Server address in configuration entry is invalid: {error}" @@ -102,9 +105,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_data = config_entry.data # Migrate config entry. + address = config_data[CONF_HOST] + api = MinecraftServer(hass, MinecraftServerType.JAVA_EDITION, address) + try: - address = config_data[CONF_HOST] - MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + await api.async_initialize() host_only_lookup_success = True except MinecraftServerAddressError as error: host_only_lookup_success = False @@ -114,9 +119,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) if not host_only_lookup_success: + address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" + api = MinecraftServer(hass, MinecraftServerType.JAVA_EDITION, address) + try: - address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" - MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + await api.async_initialize() except MinecraftServerAddressError as error: _LOGGER.exception( "Can't migrate configuration entry due to error while parsing server address, try again later: %s", diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index 4ab7865f369a0d..fc872d37bdef33 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -9,6 +9,8 @@ from mcstatus import BedrockServer, JavaServer from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +from homeassistant.core import HomeAssistant + _LOGGER = logging.getLogger(__name__) LOOKUP_TIMEOUT: float = 10 @@ -52,35 +54,51 @@ class MinecraftServerConnectionError(Exception): """Raised when no data can be fechted from the server.""" +class MinecraftServerNotInitializedError(Exception): + """Raised when APIs are used although server instance is not initialized yet.""" + + class MinecraftServer: """Minecraft Server wrapper class for 3rd party library mcstatus.""" - _server: BedrockServer | JavaServer + _server: BedrockServer | JavaServer | None - def __init__(self, server_type: MinecraftServerType, address: str) -> None: + def __init__( + self, hass: HomeAssistant, server_type: MinecraftServerType, address: str + ) -> None: """Initialize server instance.""" + self._server = None + self._hass = hass + self._server_type = server_type + self._address = address + + async def async_initialize(self) -> None: + """Perform async initialization of server instance.""" try: - if server_type == MinecraftServerType.JAVA_EDITION: - self._server = JavaServer.lookup(address, timeout=LOOKUP_TIMEOUT) + if self._server_type == MinecraftServerType.JAVA_EDITION: + self._server = await JavaServer.async_lookup(self._address) else: - self._server = BedrockServer.lookup(address, timeout=LOOKUP_TIMEOUT) + self._server = await self._hass.async_add_executor_job( + BedrockServer.lookup, self._address + ) except (ValueError, LifetimeTimeout) as error: raise MinecraftServerAddressError( - f"Lookup of '{address}' failed: {self._get_error_message(error)}" + f"Lookup of '{self._address}' failed: {self._get_error_message(error)}" ) from error self._server.timeout = DATA_UPDATE_TIMEOUT - self._address = address _LOGGER.debug( - "%s server instance created with address '%s'", server_type, address + "%s server instance created with address '%s'", + self._server_type, + self._address, ) async def async_is_online(self) -> bool: """Check if the server is online, supporting both Java and Bedrock Edition servers.""" try: await self.async_get_data() - except MinecraftServerConnectionError: + except (MinecraftServerConnectionError, MinecraftServerNotInitializedError): return False return True @@ -89,6 +107,9 @@ async def async_get_data(self) -> MinecraftServerData: """Get updated data from the server, supporting both Java and Bedrock Edition servers.""" status_response: BedrockStatusResponse | JavaStatusResponse + if self._server is None: + raise MinecraftServerNotInitializedError() + try: status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES) except OSError as error: diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 520d7342b3505d..6c0a2a248f3dc0 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -19,7 +19,7 @@ KEY_STATUS = "status" -@dataclass +@dataclass(frozen=True) class MinecraftServerBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing Minecraft Server binary sensor entities.""" diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index f064a4ac1ef4bf..045133421fba89 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -35,10 +35,10 @@ async def async_step_user(self, user_input=None) -> FlowResult: # Some Bedrock Edition servers mimic a Java Edition server, therefore check for a Bedrock Edition server first. for server_type in MinecraftServerType: + api = MinecraftServer(self.hass, server_type, address) + try: - api = await self.hass.async_add_executor_job( - MinecraftServer, server_type, address - ) + await api.async_initialize() except MinecraftServerAddressError: pass else: diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index f7a60318c64f41..e498375cafc17a 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -7,7 +7,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .api import MinecraftServer, MinecraftServerConnectionError, MinecraftServerData +from .api import ( + MinecraftServer, + MinecraftServerConnectionError, + MinecraftServerData, + MinecraftServerNotInitializedError, +) SCAN_INTERVAL = timedelta(seconds=60) @@ -32,5 +37,8 @@ async def _async_update_data(self) -> MinecraftServerData: """Get updated data from the server.""" try: return await self._api.async_get_data() - except MinecraftServerConnectionError as error: + except ( + MinecraftServerConnectionError, + MinecraftServerNotInitializedError, + ) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 661ce00dac59bf..671bbdb7a05834 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -41,7 +41,7 @@ UNIT_PLAYERS_ONLINE = "players" -@dataclass +@dataclass(frozen=True) class MinecraftServerEntityDescriptionMixin: """Mixin values for Minecraft Server entities.""" @@ -50,7 +50,7 @@ class MinecraftServerEntityDescriptionMixin: supported_server_types: set[MinecraftServerType] -@dataclass +@dataclass(frozen=True) class MinecraftServerSensorEntityDescription( SensorEntityDescription, MinecraftServerEntityDescriptionMixin ): diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 3d33af38761797..cb5c0ae5c3ddd2 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -103,6 +103,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}" webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) + async def create_cloud_hook() -> None: + """Create a cloud hook.""" + hook = await cloud.async_create_cloudhook(hass, webhook_id) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_CLOUDHOOK_URL: hook} + ) + + async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + if ( + state is cloud.CloudConnectionState.CLOUD_CONNECTED + and CONF_CLOUDHOOK_URL not in entry.data + ): + await create_cloud_hook() + + if ( + CONF_CLOUDHOOK_URL not in entry.data + and cloud.async_active_subscription(hass) + and cloud.async_is_connected(hass) + ): + await create_cloud_hook() + entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass_notify.async_reload(hass, DOMAIN) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 14f8b59ddee9ab..cc1b3c74356322 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -63,6 +63,18 @@ CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_FANS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, @@ -100,7 +112,6 @@ CONF_STOPBITS, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, @@ -123,6 +134,7 @@ from .modbus import ModbusHub, async_modbus_setup from .validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -145,7 +157,7 @@ vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.positive_int, - vol.Optional(CONF_LAZY_ERROR, default=0): cv.positive_int, + vol.Optional(CONF_LAZY_ERROR): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -178,10 +190,11 @@ vol.Optional(CONF_STRUCTURE): cv.string, vol.Optional(CONF_SCALE, default=1): number_validator, vol.Optional(CONF_OFFSET, default=0): number_validator, - vol.Optional(CONF_PRECISION, default=0): cv.positive_int, - vol.Optional(CONF_SWAP, default=CONF_SWAP_NONE): vol.In( + vol.Optional(CONF_PRECISION): cv.positive_int, + vol.Optional( + CONF_SWAP, + ): vol.In( [ - CONF_SWAP_NONE, CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, @@ -265,6 +278,26 @@ vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), + vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( + vol.All( + { + CONF_ADDRESS: cv.positive_int, + CONF_FAN_MODE_VALUES: { + vol.Optional(CONF_FAN_MODE_ON): cv.positive_int, + vol.Optional(CONF_FAN_MODE_OFF): cv.positive_int, + vol.Optional(CONF_FAN_MODE_AUTO): cv.positive_int, + vol.Optional(CONF_FAN_MODE_LOW): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MEDIUM): cv.positive_int, + vol.Optional(CONF_FAN_MODE_HIGH): cv.positive_int, + vol.Optional(CONF_FAN_MODE_TOP): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MIDDLE): cv.positive_int, + vol.Optional(CONF_FAN_MODE_FOCUS): cv.positive_int, + vol.Optional(CONF_FAN_MODE_DIFFUSE): cv.positive_int, + }, + }, + duplicate_fan_mode_validator, + ), + ), } ), ) @@ -341,7 +374,7 @@ vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, vol.Optional(CONF_CLOSE_COMM_ON_ERROR): cv.boolean, vol.Optional(CONF_DELAY, default=0): cv.positive_int, - vol.Optional(CONF_RETRIES, default=3): cv.positive_int, + vol.Optional(CONF_RETRIES): cv.positive_int, vol.Optional(CONF_RETRY_ON_EMPTY): cv.boolean, vol.Optional(CONF_MSG_WAIT): cv.positive_int, vol.Optional(CONF_BINARY_SENSORS): vol.All( diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index edfca94979e6fa..d3ec06bbdd7fd2 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -24,10 +24,11 @@ STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -55,13 +56,13 @@ CONF_STATE_ON, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VERIFY, CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, + MODBUS_DOMAIN, SIGNAL_START_ENTITY, SIGNAL_STOP_ENTITY, DataType, @@ -75,8 +76,34 @@ class BasePlatform(Entity): """Base for readonly platforms.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: + def __init__( + self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any] + ) -> None: """Initialize the Modbus binary sensor.""" + + if CONF_LAZY_ERROR in entry: + async_create_issue( + hass, + MODBUS_DOMAIN, + "removed_lazy_error_count", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="removed_lazy_error_count", + translation_placeholders={ + "config_key": "lazy_error_count", + "integration": MODBUS_DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`close_comm_on_error`: is deprecated and will be removed in version 2024.4" + ) + + _LOGGER.warning( + "`lazy_error_count`: is deprecated and will be removed in version 2024.7" + ) + self._hub = hub self._slave = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) self._address = int(entry[CONF_ADDRESS]) @@ -93,8 +120,6 @@ def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_available = True self._attr_unit_of_measurement = None - self._lazy_error_count = entry[CONF_LAZY_ERROR] - self._lazy_errors = self._lazy_error_count def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: @@ -154,18 +179,14 @@ async def async_base_added_to_hass(self) -> None: class BaseStructPlatform(BasePlatform, RestoreEntity): """Base class representing a sensor/climate.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._swap = config[CONF_SWAP] - if self._swap == CONF_SWAP_NONE: - self._swap = None self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] - self._precision = config[CONF_PRECISION] self._scale = config[CONF_SCALE] - if self._scale < 1 and not self._precision: - self._precision = 2 + self._precision = config.get(CONF_PRECISION, 2 if self._scale < 1 else 0) self._offset = config[CONF_OFFSET] self._slave_count = config.get(CONF_SLAVE_COUNT, None) or config.get( CONF_VIRTUAL_COUNT, 0 @@ -250,10 +271,10 @@ def unpack_structure_result(self, registers: list[int]) -> str | None: class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): """Base class representing a Modbus switch.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" config[CONF_INPUT_TYPE] = "" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._attr_is_on = False convert = { CALL_TYPE_REGISTER_HOLDING: ( @@ -346,15 +367,10 @@ async def async_update(self, now: datetime | None = None) -> None: ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._verify_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._attr_is_on = bool(result.bits[0] & 1) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 39174ae89311a1..6c0f6422df2bc2 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -54,7 +54,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusBinarySensor(hub, entry, slave_count) + sensor = ModbusBinarySensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -64,12 +64,18 @@ async def async_setup_platform( class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): """Modbus binary sensor.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any], slave_count: int) -> None: + def __init__( + self, + hass: HomeAssistant, + hub: ModbusHub, + entry: dict[str, Any], + slave_count: int, + ) -> None: """Initialize the Modbus binary sensor.""" self._count = slave_count + 1 self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None self._result: list[int] = [] - super().__init__(hub, entry) + super().__init__(hass, hub, entry) async def async_setup_slaves( self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any] @@ -109,14 +115,9 @@ async def async_update(self, now: datetime | None = None) -> None: ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._result = [] else: - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._result = result.bits diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index df2983e9070f7a..aa345324dc82dc 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -6,6 +6,16 @@ from typing import Any, cast from homeassistant.components.climate import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -31,6 +41,18 @@ CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -67,7 +89,7 @@ async def async_setup_platform( entities = [] for entity in discovery_info[CONF_CLIMATES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - entities.append(ModbusThermostat(hub, entity)) + entities.append(ModbusThermostat(hass, hub, entity)) async_add_entities(entities) @@ -79,11 +101,12 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus thermostat.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._target_temperature_register = config[CONF_TARGET_TEMP] self._target_temperature_write_registers = config[ CONF_TARGET_TEMP_WRITE_REGISTERS @@ -102,7 +125,6 @@ def __init__( ) self._attr_min_temp = config[CONF_MIN_TEMP] self._attr_max_temp = config[CONF_MAX_TEMP] - self._attr_target_temperature_step = config[CONF_TARGET_TEMP] self._attr_target_temperature_step = config[CONF_STEP] if CONF_HVAC_MODE_REGISTER in config: @@ -137,6 +159,42 @@ def __init__( self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.AUTO] + if CONF_FAN_MODE_REGISTER in config: + self._attr_supported_features = ( + self._attr_supported_features | ClimateEntityFeature.FAN_MODE + ) + mode_config = config[CONF_FAN_MODE_REGISTER] + self._fan_mode_register = mode_config[CONF_ADDRESS] + self._attr_fan_modes = cast(list[str], []) + self._attr_fan_mode = None + self._fan_mode_mapping_to_modbus: dict[str, int] = {} + self._fan_mode_mapping_from_modbus: dict[int, str] = {} + mode_value_config = mode_config[CONF_FAN_MODE_VALUES] + + for fan_mode_kw, fan_mode in ( + (CONF_FAN_MODE_ON, FAN_ON), + (CONF_FAN_MODE_OFF, FAN_OFF), + (CONF_FAN_MODE_AUTO, FAN_AUTO), + (CONF_FAN_MODE_LOW, FAN_LOW), + (CONF_FAN_MODE_MEDIUM, FAN_MEDIUM), + (CONF_FAN_MODE_HIGH, FAN_HIGH), + (CONF_FAN_MODE_TOP, FAN_TOP), + (CONF_FAN_MODE_MIDDLE, FAN_MIDDLE), + (CONF_FAN_MODE_FOCUS, FAN_FOCUS), + (CONF_FAN_MODE_DIFFUSE, FAN_DIFFUSE), + ): + if fan_mode_kw in mode_value_config: + value = mode_value_config[fan_mode_kw] + self._fan_mode_mapping_from_modbus[value] = fan_mode + self._fan_mode_mapping_to_modbus[fan_mode] = value + self._attr_fan_modes.append(fan_mode) + + else: + # No HVAC modes defined + self._fan_mode_register = None + self._attr_fan_mode = FAN_AUTO + self._attr_fan_modes = [FAN_AUTO] + if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] self._hvac_onoff_write_registers = config[CONF_WRITE_REGISTERS] @@ -193,6 +251,21 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: await self.async_update() + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + + if self._fan_mode_register is not None: + # Write a value to the mode register for the desired mode. + value = self._fan_mode_mapping_to_modbus[fan_mode] + await self._hub.async_pb_call( + self._slave, + self._fan_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) + + await self.async_update() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_temperature = ( @@ -254,7 +327,7 @@ async def async_update(self, now: datetime | None = None) -> None: self._input_type, self._address ) - # Read the mode register if defined + # Read the HVAC mode register if defined if self._hvac_mode_register is not None: hvac_mode = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._hvac_mode_register, raw=True @@ -268,7 +341,17 @@ async def async_update(self, now: datetime | None = None) -> None: self._attr_hvac_mode = mode break - # Read th on/off register if defined. If the value in this + # Read the Fan mode register if defined + if self._fan_mode_register is not None: + fan_mode = await self._async_read_register( + CALL_TYPE_REGISTER_HOLDING, self._fan_mode_register, raw=True + ) + + # Translate the value received + if fan_mode is not None: + self._attr_fan_mode = self._fan_mode_mapping_from_modbus[int(fan_mode)] + + # Read the on/off register if defined. If the value in this # register is "OFF", it will take precedence over the value # in the mode register. if self._hvac_onoff_register is not None: @@ -288,15 +371,9 @@ async def _async_read_register( self._slave, register, self._count, register_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return -1 - self._lazy_errors = self._lazy_error_count self._attr_available = False return -1 - self._lazy_errors = self._lazy_error_count - if raw: # Return the raw value read from the register, do not change # the object's state diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index a52f8ccfc9726f..e536a31c4f653f 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -45,13 +45,23 @@ CONF_STOPBITS = "stopbits" CONF_SWAP = "swap" CONF_SWAP_BYTE = "byte" -CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" CONF_TARGET_TEMP_WRITE_REGISTERS = "target_temp_write_registers" +CONF_FAN_MODE_REGISTER = "fan_mode_register" +CONF_FAN_MODE_ON = "state_fan_on" +CONF_FAN_MODE_OFF = "state_fan_off" +CONF_FAN_MODE_LOW = "state_fan_low" +CONF_FAN_MODE_MEDIUM = "state_fan_medium" +CONF_FAN_MODE_HIGH = "state_fan_high" +CONF_FAN_MODE_AUTO = "state_fan_auto" +CONF_FAN_MODE_TOP = "state_fan_top" +CONF_FAN_MODE_MIDDLE = "state_fan_middle" +CONF_FAN_MODE_FOCUS = "state_fan_focus" +CONF_FAN_MODE_DIFFUSE = "state_fan_diffuse" +CONF_FAN_MODE_VALUES = "values" CONF_HVAC_MODE_REGISTER = "hvac_mode_register" -CONF_HVAC_MODE_VALUES = "values" CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" @@ -60,6 +70,7 @@ CONF_HVAC_MODE_AUTO = "state_auto" CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" +CONF_HVAC_MODE_VALUES = "values" CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" CONF_VIRTUAL_COUNT = "virtual_count" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 27f9cb1fc1817a..072f1bb3d931d2 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -51,7 +51,7 @@ async def async_setup_platform( covers = [] for cover in discovery_info[CONF_COVERS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - covers.append(ModbusCover(hub, cover)) + covers.append(ModbusCover(hass, hub, cover)) async_add_entities(covers) @@ -63,11 +63,12 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus cover.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._state_closed = config[CONF_STATE_CLOSED] self._state_closing = config[CONF_STATE_CLOSING] self._state_open = config[CONF_STATE_OPEN] @@ -142,14 +143,9 @@ async def async_update(self, now: datetime | None = None) -> None: self._slave, self._address, 1, self._input_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._set_attr_state(bool(result.bits[0] & 1)) diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index a986b243c1b2a6..e5006b66f81d18 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_FANS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - fans.append(ModbusFan(hub, entry)) + fans.append(ModbusFan(hass, hub, entry)) async_add_entities(fans) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 2e5ac62be21595..acc01f39b4626e 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -29,7 +29,7 @@ async def async_setup_platform( lights = [] for entry in discovery_info[CONF_LIGHTS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - lights.append(ModbusLight(hub, entry)) + lights.append(ModbusLight(hass, hub, entry)) async_add_entities(lights) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 764cf4930f78c1..95c0cd453321f1 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -260,6 +260,26 @@ class ModbusHub: def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: """Initialize the Modbus hub.""" + if CONF_RETRIES in client_config: + async_create_issue( + hass, + DOMAIN, + "deprecated_retries", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_retries", + translation_placeholders={ + "config_key": "retries", + "integration": DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`retries`: is deprecated and will be removed in version 2024.7" + ) + else: + client_config[CONF_RETRIES] = 3 if CONF_CLOSE_COMM_ON_ERROR in client_config: async_create_issue( hass, @@ -315,7 +335,7 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: self._pb_params = { "port": client_config[CONF_PORT], "timeout": client_config[CONF_TIMEOUT], - "retries": client_config[CONF_RETRIES], + "retries": 3, "retry_on_empty": True, } if self._config_type == SERIAL: @@ -435,16 +455,24 @@ def pb_call( 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/sensor.py b/homeassistant/components/modbus/sensor.py index 52aa37535d6826..c015d117b13e57 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,7 +1,7 @@ """Support for Modbus Register sensors.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime import logging from typing import Any @@ -19,7 +19,6 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -53,7 +52,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusRegisterSensor(hub, entry, slave_count) + sensor = ModbusRegisterSensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -65,12 +64,13 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any], slave_count: int, ) -> None: """Initialize the modbus register sensor.""" - super().__init__(hub, entry) + super().__init__(hass, hub, entry) if slave_count: self._count = self._count * (slave_count + 1) self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None @@ -114,13 +114,6 @@ async def async_update(self, now: datetime | None = None) -> None: self._slave, self._address, self._count, self._input_type ) if raw_result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - self._cancel_call = async_call_later( - self.hass, timedelta(seconds=1), self.async_update - ) - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._attr_native_value = None if self._coordinator: @@ -142,7 +135,6 @@ async def async_update(self, now: datetime | None = None) -> None: else: self._attr_native_value = result self._attr_available = self._attr_native_value is not None - self._lazy_errors = self._lazy_error_count self.async_write_ha_state() diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 5f45d0df5963ba..12e66f5d2ca5cf 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -70,9 +70,17 @@ } }, "issues": { + "removed_lazy_error_count": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" + }, + "deprecated_retries": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nThe maximum number of retries is now fixed to 3." + }, "deprecated_close_comm_config": { "title": "`{config_key}` configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nCommunication is automatically closed on errors, see [the documentation]({url}) for other error handling parameters." + "description": "Please remove the `{config_key}` key from the {integration} entry in your `configuration.yaml` file and restart Home Assistant to fix this issue. All errors will be reported, as `lazy_error_count` is accepted but ignored." }, "deprecated_retry_on_empty": { "title": "`{config_key}` configuration key is being removed", diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index beb8409600685b..0c955ea409d42f 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_SWITCHES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - switches.append(ModbusSwitch(hub, entry)) + switches.append(ModbusSwitch(hass, hub, entry)) async_add_entities(switches) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index fbf56d97b51688..5e2129bd90a81c 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -26,13 +26,16 @@ from .const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_REGISTER, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, DEFAULT_HUB, @@ -52,6 +55,12 @@ "validate_parm", ], ) + + +ILLEGAL = "I" +OPTIONAL = "O" +DEMANDED = "D" + PARM_IS_LEGAL = namedtuple( "PARM_IS_LEGAL", [ @@ -62,28 +71,40 @@ "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.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) + ), } @@ -96,33 +117,33 @@ 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, config.get(CONF_VIRTUAL_COUNT, 0)) - swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) + slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT)) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm + swap_type = config.get(CONF_SWAP) + swap_dict = { + CONF_SWAP_BYTE: validator.swap_byte, + CONF_SWAP_WORD: validator.swap_word, + CONF_SWAP_WORD_BYTE: validator.swap_word, + } + swap_type_validator = swap_dict[swap_type] if swap_type else OPTIONAL 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}:", + ), + (swap_type, swap_type_validator, f"{CONF_SWAP}:{swap_type}"), ): - if bool(entry[0]) != entry[1]: - error = "cannot be combined" if not entry[1] else "missing, demanded" - error = ( - f"{name}: `{entry[2]}:` {error} with `{CONF_DATA_TYPE}: {data_type}`" - ) + 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 slave_count and not validator.slave_count: - error = f"{name}: `{CONF_VIRTUAL_COUNT} / {CONF_SLAVE_COUNT}:` cannot be combined 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_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}`" - raise vol.Invalid(error) if config[CONF_DATA_TYPE] == DataType.CUSTOM: try: size = struct.calcsize(structure) @@ -243,12 +264,31 @@ def duplicate_entity_validator(config: dict) -> dict: addr += "_" + str(entry[CONF_COMMAND_OFF]) inx = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) addr += "_" + str(inx) - if addr in addresses: - err = ( - f"Modbus {component}/{name} address {addr} is duplicate, second" - " entry not loaded!" - ) - _LOGGER.warning(err) + entry_addrs: set[str] = set() + entry_addrs.add(addr) + + if CONF_TARGET_TEMP in entry: + a = str(entry[CONF_TARGET_TEMP]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_HVAC_MODE_REGISTER in entry: + a = str(entry[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_FAN_MODE_REGISTER in entry: + a = str(entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + + dup_addrs = entry_addrs.intersection(addresses) + + if len(dup_addrs) > 0: + for addr in dup_addrs: + err = ( + f"Modbus {component}/{name} address {addr} is duplicate, second" + " entry not loaded!" + ) + _LOGGER.warning(err) errors.append(index) elif name in names: err = ( @@ -259,7 +299,7 @@ def duplicate_entity_validator(config: dict) -> dict: errors.append(index) else: names.add(name) - addresses.add(addr) + addresses.update(entry_addrs) for i in reversed(errors): del config[hub_index][conf_key][i] @@ -278,11 +318,11 @@ def duplicate_modbus_validator(config: list) -> list: else: host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}" if host in hosts: - err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!" + err = f"Modbus {name} contains duplicate host/port {host}, not loaded!" _LOGGER.warning(err) errors.append(index) elif name in names: - err = f"Modbus {name}  is duplicate, second entry not loaded!" + err = f"Modbus {name} is duplicate, second entry not loaded!" _LOGGER.warning(err) errors.append(index) else: @@ -292,3 +332,20 @@ def duplicate_modbus_validator(config: list) -> list: for i in reversed(errors): del config[i] return config + + +def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: + """Control modbus climate fan mode values for duplicates.""" + fan_modes: set[int] = set() + errors = [] + for key, value in config[CONF_FAN_MODE_VALUES].items(): + if value in fan_modes: + wrn = f"Modbus fan mode {key} has a duplicate value {value}, not loaded, values must be unique!" + _LOGGER.warning(wrn) + errors.append(key) + else: + fan_modes.add(value) + + for key in reversed(errors): + del config[CONF_FAN_MODE_VALUES][key] + return config diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 9d5a3c3223557a..e6bcff715b8983 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -12,10 +12,10 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import ( ModernFormsDataUpdateCoordinator, diff --git a/homeassistant/components/modern_forms/strings.json b/homeassistant/components/modern_forms/strings.json index dd47ef721af316..e6d0f6a2206e65 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/binary_sensor.py b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py index 8acc88d83141e9..5cdca72fa55226 100644 --- a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py @@ -27,6 +27,7 @@ async def async_setup_entry( Alpha2IODeviceBatterySensor(coordinator, io_device_id) for io_device_id, io_device in coordinator.data["io_devices"].items() if io_device["_HEATAREA_ID"] + and io_device["_HEATAREA_ID"] in coordinator.data["heat_areas"] ) diff --git a/homeassistant/components/moehlenhoff_alpha2/sensor.py b/homeassistant/components/moehlenhoff_alpha2/sensor.py index e41c6b041f61be..2c2e44f451d955 100644 --- a/homeassistant/components/moehlenhoff_alpha2/sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( Alpha2HeatControlValveOpeningSensor(coordinator, heat_control_id) for heat_control_id, heat_control in coordinator.data["heat_controls"].items() if heat_control["INUSE"] - and heat_control["_HEATAREA_ID"] + and heat_control["_HEATAREA_ID"] in coordinator.data["heat_areas"] and heat_control.get("ACTOR_PERCENT") is not None ) diff --git a/homeassistant/components/moehlenhoff_alpha2/strings.json b/homeassistant/components/moehlenhoff_alpha2/strings.json index 3347b2f318c14f..d15ec9f89ebb3c 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 d6b5618bf97eb7..766af7154855f6 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/manifest.json b/homeassistant/components/motion_blinds/manifest.json index cc31ff42edf760..f9115cd8146dfd 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.18"] + "requirements": ["motionblinds==0.6.19"] } diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index d8dc25e000666a..e71abe09069e5f 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/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 59fc41df9b0661..37519a236ab135 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -111,7 +111,7 @@ def get_motioneye_device_identifier( def split_motioneye_device_identifier( - identifier: tuple[str, str] + identifier: tuple[str, str], ) -> tuple[str, str, int] | None: """Get the identifiers for a motionEye device.""" if len(identifier) != 2 or identifier[0] != DOMAIN or "_" not in identifier[1]: diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py new file mode 100644 index 00000000000000..8baceb104c3a33 --- /dev/null +++ b/homeassistant/components/motionmount/__init__.py @@ -0,0 +1,61 @@ +"""The Vogel's MotionMount integration.""" +from __future__ import annotations + +import socket + +import motionmount + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, EMPTY_MAC + +PLATFORMS: list[Platform] = [ + Platform.NUMBER, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Vogel's MotionMount from a config entry.""" + + host = entry.data[CONF_HOST] + + # Create API instance + mm = motionmount.MotionMount(host, entry.data[CONF_PORT]) + + # Validate the API connection + try: + await mm.connect() + except (ConnectionError, TimeoutError, socket.gaierror) as ex: + raise ConfigEntryNotReady(f"Failed to connect to {host}") from ex + + found_mac = format_mac(mm.mac.hex()) + if found_mac not in (EMPTY_MAC, entry.unique_id): + # If the mac address of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + await mm.disconnect() + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" + ) + + # Store an API object for your platforms to access + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm + + 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): + mm: motionmount.MotionMount = hass.data[DOMAIN].pop(entry.entry_id) + await mm.disconnect() + + return unload_ok diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py new file mode 100644 index 00000000000000..a593b30201e900 --- /dev/null +++ b/homeassistant/components/motionmount/config_flow.py @@ -0,0 +1,176 @@ +"""Config flow for Vogel's MotionMount.""" +import logging +import socket +from typing import Any + +import motionmount +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, EMPTY_MAC + +_LOGGER = logging.getLogger(__name__) + + +# A MotionMount can be in four states: +# 1. Old CE and old Pro FW -> It doesn't supply any kind of mac +# 2. Old CE but new Pro FW -> It supplies its mac using DNS-SD, but a read of the mac fails +# 3. New CE but old Pro FW -> It doesn't supply the mac using DNS-SD but we can read it (returning the EMPTY_MAC) +# 4. New CE and new Pro FW -> Both DNS-SD and a read gives us the mac +# If we can't get the mac, we use DEFAULT_DISCOVERY_UNIQUE_ID as an ID, so we can always configure a single MotionMount. Most households will only have a single MotionMount +class MotionMountFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Vogel's MotionMount config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Set up the instance.""" + self.discovery_info: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_setup_form() + + info = {} + try: + info = await self._validate_input(user_input) + except (ConnectionError, socket.gaierror): + return self.async_abort(reason="cannot_connect") + except TimeoutError: + return self.async_abort(reason="time_out") + except motionmount.NotConnectedError: + return self.async_abort(reason="not_connected") + except motionmount.MotionMountResponseError: + # This is most likely due to missing support for the mac address property + # Abort if the handler has config entries already + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + # Otherwise we try to continue with the generic uid + info[CONF_UUID] = config_entries.DEFAULT_DISCOVERY_UNIQUE_ID + + # If the device mac is valid we use it, otherwise we use the default id + if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: + unique_id = info[CONF_UUID] + else: + unique_id = config_entries.DEFAULT_DISCOVERY_UNIQUE_ID + + name = info.get(CONF_NAME, user_input[CONF_HOST]) + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + + return self.async_create_entry(title=name, data=user_input) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + + # Extract information from discovery + host = discovery_info.hostname + port = discovery_info.port + zctype = discovery_info.type + name = discovery_info.name.removesuffix(f".{zctype}") + unique_id = discovery_info.properties.get("mac") + + self.discovery_info.update( + { + CONF_HOST: host, + CONF_PORT: port, + CONF_NAME: name, + } + ) + + if unique_id: + # If we already have the unique id, try to set it now + # so we can avoid probing the device if its already + # configured or ignored + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port} + ) + else: + # Avoid probing devices that already have an entry + self._async_abort_entries_match({CONF_HOST: host}) + + self.context.update({"title_placeholders": {"name": name}}) + + try: + info = await self._validate_input(self.discovery_info) + except (ConnectionError, socket.gaierror): + return self.async_abort(reason="cannot_connect") + except TimeoutError: + return self.async_abort(reason="time_out") + except motionmount.NotConnectedError: + return self.async_abort(reason="not_connected") + except motionmount.MotionMountResponseError: + info = {} + # We continue as we want to be able to connect with older FW that does not support MAC address + + # If the device supplied as with a valid MAC we use that + if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: + unique_id = info[CONF_UUID] + + if unique_id: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port} + ) + else: + await self._async_handle_discovery_without_unique_id() + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a confirmation flow initiated by zeroconf.""" + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]}, + errors={}, + ) + + return self.async_create_entry( + title=self.discovery_info[CONF_NAME], + data=self.discovery_info, + ) + + async def _validate_input(self, data: dict) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + + mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) + try: + await mm.connect() + finally: + await mm.disconnect() + + return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name} + + def _show_setup_form(self, errors: dict[str, str] | None = None) -> FlowResult: + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=23): int, + } + ), + errors=errors or {}, + ) diff --git a/homeassistant/components/motionmount/const.py b/homeassistant/components/motionmount/const.py new file mode 100644 index 00000000000000..92045193ad6405 --- /dev/null +++ b/homeassistant/components/motionmount/const.py @@ -0,0 +1,5 @@ +"""Constants for the Vogel's MotionMount integration.""" + +DOMAIN = "motionmount" + +EMPTY_MAC = "00:00:00:00:00:00" diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py new file mode 100644 index 00000000000000..c3f7c9c9358dfe --- /dev/null +++ b/homeassistant/components/motionmount/entity.py @@ -0,0 +1,53 @@ +"""Support for MotionMount sensors.""" + +import motionmount + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, EMPTY_MAC + + +class MotionMountEntity(Entity): + """Representation of a MotionMount entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize general MotionMount entity.""" + self.mm = mm + mac = format_mac(mm.mac.hex()) + + # Create a base unique id + if mac == EMPTY_MAC: + self._base_unique_id = config_entry.entry_id + else: + self._base_unique_id = mac + + # Set device info + self._attr_device_info = DeviceInfo( + name=mm.name, + manufacturer="Vogel's", + model="TVM 7675", + ) + + if mac == EMPTY_MAC: + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, config_entry.entry_id)} + else: + self._attr_device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, mac) + } + + async def async_added_to_hass(self) -> None: + """Store register state change callback.""" + self.mm.add_listener(self.async_write_ha_state) + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Remove register state change callback.""" + self.mm.remove_listener(self.async_write_ha_state) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json new file mode 100644 index 00000000000000..bfe7e21fce97a8 --- /dev/null +++ b/homeassistant/components/motionmount/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "motionmount", + "name": "Vogel's MotionMount", + "codeowners": ["@RJPoelstra"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/motionmount", + "integration_type": "device", + "iot_class": "local_push", + "requirements": ["python-MotionMount==0.3.1"], + "zeroconf": ["_tvm._tcp.local."] +} diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py new file mode 100644 index 00000000000000..476e14c3a8217c --- /dev/null +++ b/homeassistant/components/motionmount/number.py @@ -0,0 +1,71 @@ +"""Support for MotionMount numeric control.""" +import motionmount + +from homeassistant.components.number import NumberEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import MotionMountEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Vogel's MotionMount from a config entry.""" + mm: motionmount.MotionMount = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ( + MotionMountExtension(mm, entry), + MotionMountTurn(mm, entry), + ) + ) + + +class MotionMountExtension(MotionMountEntity, NumberEntity): + """The target extension position of a MotionMount.""" + + _attr_native_max_value = 100 + _attr_native_min_value = 0 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_translation_key = "motionmount_extension" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize Extension number.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-extension" + + @property + def native_value(self) -> float: + """Get native value.""" + return float(self.mm.extension or 0) + + async def async_set_native_value(self, value: float) -> None: + """Set the new value for extension.""" + await self.mm.set_extension(int(value)) + + +class MotionMountTurn(MotionMountEntity, NumberEntity): + """The target turn position of a MotionMount.""" + + _attr_native_max_value = 100 + _attr_native_min_value = -100 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_translation_key = "motionmount_turn" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize Turn number.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-turn" + + @property + def native_value(self) -> float: + """Get native value.""" + return float(self.mm.turn or 0) * -1 + + async def async_set_native_value(self, value: float) -> None: + """Set the new value for turn.""" + await self.mm.set_turn(int(value * -1)) diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json new file mode 100644 index 00000000000000..00a409f3058361 --- /dev/null +++ b/homeassistant/components/motionmount/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Link your MotionMount", + "description": "Set up your MotionMount to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "zeroconf_confirm": { + "description": "Do you want to set up {name}?", + "title": "Discovered MotionMount" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "time_out": "Failed to connect due to a time out.", + "not_connected": "Failed to connect.", + "invalid_response": "Failed to connect due to an invalid response from the MotionMount." + } + }, + "entity": { + "number": { + "motionmount_extension": { + "name": "Extension" + }, + "motionmount_turn": { + "name": "Turn" + } + } + } +} diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 8eab83b5d418f5..9b3adb38e0c5b0 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 @@ def __init__(self, server, port, password, name): 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 @@ def __init__(self, server, port, password, name): 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 - - 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 + self._client.idletimeout = 10 + self._client_lock = asyncio.Lock() + + # 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 _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() + async def async_update(self) -> None: + """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() - if (position := self._status.get("elapsed")) is None: - position = self._status.get("time") + 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 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)) + 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() + await self._update_playlists() + except (mpd.ConnectionError, ValueError) as error: + _LOGGER.debug("Error updating status: %s", error) @property - def available(self): + def available(self) -> bool: """Return true if MPD is available and connected.""" - return self._is_connected - - 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()) - - 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() + return self._is_available is True @property def name(self): @@ -174,13 +197,13 @@ def name(self): @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 @@ def media_image_hash(self): 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 - - image = bytes(response["binary"]) - mime = response.get( - "type", "image/png" - ) # readpicture has type, albumart does not - return (image, mime) + async with self.connection(): + if self._currentsong is None or not (file := self._currentsong.get("file")): + return None, None + + 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 @@ async def _async_update_media_image_hash(self): 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 @@ async def _async_get_file_image_response(self, file): # 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 @@ def volume_level(self): @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 @@ async def _update_playlists(self, **kwargs: Any) -> None: """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 @@ async def async_play_media( 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) - - if media_type == MediaType.PLAYLIST: - _LOGGER.debug("Playing playlist: %s", media_id) - if media_id in self._playlists: - self._currentplaylist = media_id + 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 + 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 @@ async def async_browse_media( 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 effff9fdf12452..593d5bbd2029c5 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -25,7 +25,7 @@ ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( - HomeAssistantError, + ConfigValidationError, ServiceValidationError, TemplateError, Unauthorized, @@ -245,11 +245,10 @@ 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 ServiceValidationError( message, @@ -258,7 +257,7 @@ async def async_check_config_schema( translation_placeholders={ "domain": domain, }, - ) from ex + ) from exc async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -417,14 +416,18 @@ async def async_setup_reload_service() -> None: 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/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index eb9ab56208e4c3..64d8c27f1de1d1 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -166,6 +166,7 @@ "pl_ton": "payload_turn_on", "pl_trig": "payload_trigger", "pl_unlk": "payload_unlock", + "pos": "reports_position", "pos_clsd": "position_closed", "pos_open": "position_open", "pow_cmd_t": "power_command_topic", diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 2e4d49b4cd9f93..c87d4c9244a989 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 @@ def _async_untrack_subscription(self, subscription: Subscription) -> None: 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 358fa6eb675d64..65ffd4d17c09a4 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -256,7 +256,7 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: CONF_HUMIDITY_STATE_TOPIC in config and CONF_HUMIDITY_COMMAND_TOPIC not in config ): - raise ValueError( + raise vol.Invalid( f"{CONF_HUMIDITY_STATE_TOPIC} cannot be used without" f" {CONF_HUMIDITY_COMMAND_TOPIC}" ) @@ -417,8 +417,8 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): climate and water_heater platforms. """ - _attr_target_temperature_low: float | None = None - _attr_target_temperature_high: float | None = None + _attr_target_temperature_low: float | None + _attr_target_temperature_high: float | None _feature_preset_mode: bool = False _optimistic: bool @@ -470,9 +470,10 @@ def handle_climate_attribute_received( 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 @@ -607,6 +608,8 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED + _attr_target_temperature_low: float | None = None + _attr_target_temperature_high: float | None = None @staticmethod def config_schema() -> vol.Schema: diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 71260dc023913c..0f2d617930db68 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -53,6 +53,7 @@ Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), Platform.UPDATE.value: vol.All(cv.ensure_list, [dict]), Platform.VACUUM.value: vol.All(cv.ensure_list, [dict]), + Platform.VALVE.value: vol.All(cv.ensure_list, [dict]), Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]), } ) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 685e45700b54de..50ea3860d9e180 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -42,9 +42,18 @@ CONF_MODE_LIST = "modes" CONF_MODE_STATE_TEMPLATE = "mode_state_template" CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_PAYLOAD_CLOSE = "payload_close" +CONF_PAYLOAD_OPEN = "payload_open" +CONF_PAYLOAD_STOP = "payload_stop" +CONF_POSITION_CLOSED = "position_closed" +CONF_POSITION_OPEN = "position_open" CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRECISION = "precision" +CONF_STATE_CLOSED = "state_closed" +CONF_STATE_CLOSING = "state_closing" +CONF_STATE_OPEN = "state_open" +CONF_STATE_OPENING = "state_opening" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" @@ -81,11 +90,16 @@ DEFAULT_OPTIMISTIC = False DEFAULT_QOS = 0 DEFAULT_PAYLOAD_AVAILABLE = "online" +DEFAULT_PAYLOAD_CLOSE = "CLOSE" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" +DEFAULT_PAYLOAD_OPEN = "OPEN" DEFAULT_PORT = 1883 DEFAULT_RETAIN = False DEFAULT_WS_HEADERS: dict[str, str] = {} DEFAULT_WS_PATH = "/" +DEFAULT_POSITION_CLOSED = 0 +DEFAULT_POSITION_OPEN = 100 +DEFAULT_RETAIN = False PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" @@ -146,6 +160,7 @@ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, ] @@ -173,5 +188,6 @@ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c8da14e67e6f7d..dce827742053c9 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -32,16 +32,34 @@ from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) from . import subscription from .config import MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_PAYLOAD_STOP, + CONF_POSITION_CLOSED, + CONF_POSITION_OPEN, CONF_QOS, CONF_RETAIN, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, CONF_STATE_TOPIC, DEFAULT_OPTIMISTIC, + DEFAULT_PAYLOAD_CLOSE, + DEFAULT_PAYLOAD_OPEN, + DEFAULT_POSITION_CLOSED, + DEFAULT_POSITION_OPEN, + DEFAULT_RETAIN, ) from .debug_info import log_messages from .mixins import ( @@ -64,15 +82,6 @@ CONF_TILT_STATUS_TOPIC = "tilt_status_topic" CONF_TILT_STATUS_TEMPLATE = "tilt_status_template" -CONF_PAYLOAD_CLOSE = "payload_close" -CONF_PAYLOAD_OPEN = "payload_open" -CONF_PAYLOAD_STOP = "payload_stop" -CONF_POSITION_CLOSED = "position_closed" -CONF_POSITION_OPEN = "position_open" -CONF_STATE_CLOSED = "state_closed" -CONF_STATE_CLOSING = "state_closing" -CONF_STATE_OPEN = "state_open" -CONF_STATE_OPENING = "state_opening" CONF_STATE_STOPPED = "state_stopped" CONF_TILT_CLOSED_POSITION = "tilt_closed_value" CONF_TILT_MAX = "tilt_max" @@ -84,13 +93,10 @@ COVER_PAYLOAD = "cover" DEFAULT_NAME = "MQTT Cover" -DEFAULT_PAYLOAD_CLOSE = "CLOSE" -DEFAULT_PAYLOAD_OPEN = "OPEN" -DEFAULT_PAYLOAD_STOP = "STOP" -DEFAULT_POSITION_CLOSED = 0 -DEFAULT_POSITION_OPEN = 100 -DEFAULT_RETAIN = False + DEFAULT_STATE_STOPPED = "stopped" +DEFAULT_PAYLOAD_STOP = "STOP" + DEFAULT_TILT_CLOSED_POSITION = 0 DEFAULT_TILT_MAX = 100 DEFAULT_TILT_MIN = 0 @@ -239,6 +245,10 @@ class MqttCover(MqttEntity, CoverEntity): _entity_id_format: str = cover.ENTITY_ID_FORMAT _optimistic: bool _tilt_optimistic: bool + _tilt_closed_percentage: int + _tilt_open_percentage: int + _pos_range: tuple[int, int] + _tilt_range: tuple[int, int] @staticmethod def config_schema() -> vol.Schema: @@ -246,6 +256,15 @@ def config_schema() -> vol.Schema: return DISCOVERY_SCHEMA def _setup_from_config(self, config: ConfigType) -> None: + """Set up cover from config.""" + self._pos_range = (config[CONF_POSITION_CLOSED] + 1, config[CONF_POSITION_OPEN]) + self._tilt_range = (config[CONF_TILT_MIN] + 1, config[CONF_TILT_MAX]) + self._tilt_closed_percentage = ranged_value_to_percentage( + self._tilt_range, config[CONF_TILT_CLOSED_POSITION] + ) + self._tilt_open_percentage = ranged_value_to_percentage( + self._tilt_range, config[CONF_TILT_OPEN_POSITION] + ) no_position = ( config.get(CONF_SET_POSITION_TOPIC) is None and config.get(CONF_GET_POSITION_TOPIC) is None @@ -284,23 +303,22 @@ def _setup_from_config(self, config: ConfigType) -> None: ) template_config_attributes = { - "position_open": self._config[CONF_POSITION_OPEN], - "position_closed": self._config[CONF_POSITION_CLOSED], - "tilt_min": self._config[CONF_TILT_MIN], - "tilt_max": self._config[CONF_TILT_MAX], + "position_open": config[CONF_POSITION_OPEN], + "position_closed": config[CONF_POSITION_CLOSED], + "tilt_min": config[CONF_TILT_MIN], + "tilt_max": config[CONF_TILT_MAX], } self._value_template = MqttValueTemplate( - self._config.get(CONF_VALUE_TEMPLATE), - entity=self, + config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value self._set_position_template = MqttCommandTemplate( - self._config.get(CONF_SET_POSITION_TEMPLATE), entity=self + config.get(CONF_SET_POSITION_TEMPLATE), entity=self ).async_render self._get_position_template = MqttValueTemplate( - self._config.get(CONF_GET_POSITION_TEMPLATE), + config.get(CONF_GET_POSITION_TEMPLATE), entity=self, config_attributes=template_config_attributes, ).async_render_with_possible_json_value @@ -380,7 +398,11 @@ def state_message_received(msg: ReceiveMessage) -> None: 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]: @@ -439,19 +461,17 @@ def position_message_received(msg: ReceiveMessage) -> None: payload = payload_dict["position"] try: - percentage_payload = self.find_percentage_in_range( - float(payload), COVER_PAYLOAD + percentage_payload = ranged_value_to_percentage( + self._pos_range, float(payload) ) except ValueError: _LOGGER.warning("Payload '%s' is not numeric", payload) return - self._attr_current_cover_position = percentage_payload + self._attr_current_cover_position = min(100, max(0, percentage_payload)) if self._config.get(CONF_STATE_TOPIC) is None: self._update_state( - STATE_CLOSED - if percentage_payload == DEFAULT_POSITION_CLOSED - else STATE_OPEN + STATE_CLOSED if self.current_cover_position == 0 else STATE_OPEN ) if self._config.get(CONF_GET_POSITION_TOPIC): @@ -502,9 +522,7 @@ async def async_open_cover(self, **kwargs: Any) -> None: # Optimistically assume that cover has changed state. self._update_state(STATE_OPEN) if self._config.get(CONF_GET_POSITION_TOPIC): - self._attr_current_cover_position = self.find_percentage_in_range( - self._config[CONF_POSITION_OPEN], COVER_PAYLOAD - ) + self._attr_current_cover_position = 100 self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: @@ -523,9 +541,7 @@ async def async_close_cover(self, **kwargs: Any) -> None: # Optimistically assume that cover has changed state. self._update_state(STATE_CLOSED) if self._config.get(CONF_GET_POSITION_TOPIC): - self._attr_current_cover_position = self.find_percentage_in_range( - self._config[CONF_POSITION_CLOSED], COVER_PAYLOAD - ) + self._attr_current_cover_position = 0 self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -561,9 +577,7 @@ async def async_open_cover_tilt(self, **kwargs: Any) -> None: self._config[CONF_ENCODING], ) if self._tilt_optimistic: - self._attr_current_cover_tilt_position = self.find_percentage_in_range( - float(self._config[CONF_TILT_OPEN_POSITION]) - ) + self._attr_current_cover_tilt_position = self._tilt_open_percentage self.async_write_ha_state() async def async_close_cover_tilt(self, **kwargs: Any) -> None: @@ -588,58 +602,60 @@ async def async_close_cover_tilt(self, **kwargs: Any) -> None: self._config[CONF_ENCODING], ) if self._tilt_optimistic: - self._attr_current_cover_tilt_position = self.find_percentage_in_range( - float(self._config[CONF_TILT_CLOSED_POSITION]) - ) + self._attr_current_cover_tilt_position = self._tilt_closed_percentage self.async_write_ha_state() async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - tilt = kwargs[ATTR_TILT_POSITION] - percentage_tilt = tilt - tilt = self.find_in_range_from_percent(tilt) + tilt_percentage = kwargs[ATTR_TILT_POSITION] + tilt_ranged = round( + percentage_to_ranged_value(self._tilt_range, tilt_percentage) + ) # Handover the tilt after calculated from percent would make it more # consistent with receiving templates variables = { - "tilt_position": percentage_tilt, + "tilt_position": tilt_percentage, "entity_id": self.entity_id, "position_open": self._config.get(CONF_POSITION_OPEN), "position_closed": self._config.get(CONF_POSITION_CLOSED), "tilt_min": self._config.get(CONF_TILT_MIN), "tilt_max": self._config.get(CONF_TILT_MAX), } - tilt = self._set_tilt_template(tilt, variables=variables) + tilt_rendered = self._set_tilt_template(tilt_ranged, variables=variables) await self.async_publish( self._config[CONF_TILT_COMMAND_TOPIC], - tilt, + tilt_rendered, self._config[CONF_QOS], self._config[CONF_RETAIN], self._config[CONF_ENCODING], ) if self._tilt_optimistic: _LOGGER.debug("Set tilt value optimistic") - self._attr_current_cover_tilt_position = percentage_tilt + self._attr_current_cover_tilt_position = tilt_percentage self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - position = kwargs[ATTR_POSITION] - percentage_position = position - position = self.find_in_range_from_percent(position, COVER_PAYLOAD) + position_percentage = kwargs[ATTR_POSITION] + position_ranged = round( + percentage_to_ranged_value(self._pos_range, position_percentage) + ) variables = { - "position": percentage_position, + "position": position_percentage, "entity_id": self.entity_id, "position_open": self._config[CONF_POSITION_OPEN], "position_closed": self._config[CONF_POSITION_CLOSED], "tilt_min": self._config[CONF_TILT_MIN], "tilt_max": self._config[CONF_TILT_MAX], } - position = self._set_position_template(position, variables=variables) + position_rendered = self._set_position_template( + position_ranged, variables=variables + ) await self.async_publish( self._config[CONF_SET_POSITION_TOPIC], - position, + position_rendered, self._config[CONF_QOS], self._config[CONF_RETAIN], self._config[CONF_ENCODING], @@ -647,87 +663,37 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: if self._optimistic: self._update_state( STATE_CLOSED - if percentage_position == self._config[CONF_POSITION_CLOSED] + if position_percentage <= self._config[CONF_POSITION_CLOSED] else STATE_OPEN ) - self._attr_current_cover_position = percentage_position + self._attr_current_cover_position = position_percentage self.async_write_ha_state() async def async_toggle_tilt(self, **kwargs: Any) -> None: """Toggle the entity.""" - if self.is_tilt_closed(): + if ( + self.current_cover_tilt_position is not None + and self.current_cover_tilt_position <= self._tilt_closed_percentage + ): await self.async_open_cover_tilt(**kwargs) else: await self.async_close_cover_tilt(**kwargs) - def is_tilt_closed(self) -> bool: - """Return if the cover is tilted closed.""" - return self._attr_current_cover_tilt_position == self.find_percentage_in_range( - float(self._config[CONF_TILT_CLOSED_POSITION]) - ) - - def find_percentage_in_range( - self, position: float, range_type: str = TILT_PAYLOAD - ) -> int: - """Find the 0-100% value within the specified range.""" - # the range of motion as defined by the min max values - if range_type == COVER_PAYLOAD: - max_range: int = self._config[CONF_POSITION_OPEN] - min_range: int = self._config[CONF_POSITION_CLOSED] - else: - max_range = self._config[CONF_TILT_MAX] - min_range = self._config[CONF_TILT_MIN] - current_range = max_range - min_range - # offset to be zero based - offset_position = position - min_range - position_percentage = round(float(offset_position) / current_range * 100.0) - - max_percent = 100 - min_percent = 0 - position_percentage = min(max(position_percentage, min_percent), max_percent) - - return position_percentage - - def find_in_range_from_percent( - self, percentage: float, range_type: str = TILT_PAYLOAD - ) -> int: - """Find the adjusted value for 0-100% within the specified range. - - if the range is 80-180 and the percentage is 90 - this method would determine the value to send on the topic - by offsetting the max and min, getting the percentage value and - returning the offset - """ - if range_type == COVER_PAYLOAD: - max_range: int = self._config[CONF_POSITION_OPEN] - min_range: int = self._config[CONF_POSITION_CLOSED] - else: - max_range = self._config[CONF_TILT_MAX] - min_range = self._config[CONF_TILT_MIN] - offset = min_range - current_range = max_range - min_range - position = round(current_range * (percentage / 100.0)) - position += offset - - return position - @callback def tilt_payload_received(self, _payload: Any) -> None: """Set the tilt value.""" try: - payload = int(round(float(_payload))) + payload = round(float(_payload)) except ValueError: _LOGGER.warning("Payload '%s' is not numeric", _payload) return if ( - self._config[CONF_TILT_MIN] <= int(payload) <= self._config[CONF_TILT_MAX] - or self._config[CONF_TILT_MAX] - <= int(payload) - <= self._config[CONF_TILT_MIN] + self._config[CONF_TILT_MIN] <= payload <= self._config[CONF_TILT_MAX] + or self._config[CONF_TILT_MAX] <= payload <= self._config[CONF_TILT_MIN] ): - level = self.find_percentage_in_range(payload) + level = ranged_value_to_percentage(self._tilt_range, payload) self._attr_current_cover_tilt_position = level else: _LOGGER.warning( diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index c78319bb46a58b..84163e217df7fd 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -74,6 +74,7 @@ "text", "update", "vacuum", + "valve", "water_heater", } diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index c9302bf65b1a05..351eb422edcdeb 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -35,7 +35,6 @@ MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entity_entry_helper, - write_state_on_attr_change, ) from .models import ( MqttValueTemplate, @@ -43,6 +42,7 @@ ReceiveMessage, ReceivePayloadType, ) +from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -120,9 +120,15 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"state"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" + if msg.retain: + _LOGGER.debug( + "Ignoring event trigger from replayed retained payload '%s' on topic %s", + msg.payload, + msg.topic, + ) + return event_attributes: dict[str, Any] = {} event_type: str payload = self._template(msg.payload, PayloadSentinel.DEFAULT) @@ -183,6 +189,8 @@ def message_received(msg: ReceiveMessage) -> None: payload, ) return + mqtt_data = get_mqtt_data(self.hass) + mqtt_data.state_write_requests.write_state_request(self) topics["state_topic"] = { "topic": self._config[CONF_STATE_TOPIC], diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 0e9e7d708e9863..24783e171c82cd 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -31,10 +31,10 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import subscription from .config import MQTT_RW_SCHEMA @@ -553,8 +553,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 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 6f70ff34051981..3479f1611d832c 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -3,7 +3,7 @@ from contextlib import suppress import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import voluptuous as vol @@ -367,10 +367,10 @@ def state_received(msg: ReceiveMessage) -> None: if brightness_supported(self.supported_color_modes): try: if brightness := values["brightness"]: - self._attr_brightness = int( - brightness # type: ignore[operator] - / float(self._config[CONF_BRIGHTNESS_SCALE]) - * 255 + if TYPE_CHECKING: + assert isinstance(brightness, float) + self._attr_brightness = color_util.value_to_brightness( + (1, self._config[CONF_BRIGHTNESS_SCALE]), brightness ) else: _LOGGER.debug( @@ -406,6 +406,9 @@ def state_received(msg: ReceiveMessage) -> None: values["color_temp"], self.entity_id, ) + # Allow to switch back to color_temp + if "color" not in values: + self._attr_hs_color = None if self.supported_features and LightEntityFeature.EFFECT: with suppress(KeyError): @@ -591,13 +594,12 @@ async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 self._set_flash_and_transition(message, **kwargs) if ATTR_BRIGHTNESS in kwargs and self._config[CONF_BRIGHTNESS]: - brightness_normalized = kwargs[ATTR_BRIGHTNESS] / DEFAULT_BRIGHTNESS_SCALE - brightness_scale = self._config[CONF_BRIGHTNESS_SCALE] - device_brightness = min( - round(brightness_normalized * brightness_scale), brightness_scale + device_brightness = color_util.brightness_to_value( + (1, self._config[CONF_BRIGHTNESS_SCALE]), + kwargs[ATTR_BRIGHTNESS], ) # Make sure the brightness is not rounded down to 0 - device_brightness = max(device_brightness, 1) + device_brightness = max(round(device_brightness), 1) message["brightness"] = device_brightness if self._optimistic: diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index d84f430bd8563d..412664ceedf79f 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -139,7 +139,6 @@ MQTT_ATTRIBUTES_BLOCKED = { "assumed_state", "available", - "context_recent_time", "device_class", "device_info", "entity_category", @@ -457,8 +456,8 @@ def _async_setup_entities() -> None: 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))) @@ -1136,7 +1135,7 @@ def _cleanup_discovery_on_remove(self) -> None: def device_info_from_specifications( - specifications: dict[str, Any] | None + specifications: dict[str, Any] | None, ) -> DeviceInfo | None: """Return a device description for device registry.""" if not specifications: diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 2da2527ad7b2ff..63b8d537170f2d 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -247,15 +247,15 @@ def async_render_with_possible_json_value( 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/strings.json b/homeassistant/components/mqtt/strings.json index 7f8dcfedd9a427..fac2f32d284dee 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -51,6 +51,24 @@ "transport": "MQTT transport", "ws_headers": "WebSocket headers in JSON format", "ws_path": "WebSocket path" + }, + "data_description": { + "broker": "The hostname or IP address of your MQTT broker.", + "port": "The port your MQTT broker listens to. For example 1883.", + "username": "The username to login to your MQTT broker.", + "password": "The password to login to your MQTT broker.", + "advanced_options": "Enable and click `next` to set advanced options.", + "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", + "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", + "client_cert": "The client certificate to authenticate against your MQTT broker.", + "client_key": "The private key file that belongs to your client certificate.", + "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", + "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", + "set_ca_cert": "Select `Auto` for automatic CA validation, or `Custom` and click `next` to set a custom CA certificate, to allow validating your MQTT brokers certificate.", + "set_client_cert": "Enable and click `next` to set a client certifificate and private key to authenticate against your MQTT broker.", + "transport": "The transport to be used for the connection to your MQTT broker.", + "ws_headers": "The WebSocket headers to pass through the WebSocket based connection to your MQTT broker.", + "ws_path": "The WebSocket path to be used for the connection to your MQTT broker." } }, "hassio_confirm": { @@ -58,6 +76,9 @@ "description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the add-on {addon}?", "data": { "discovery": "Enable discovery" + }, + "data_description": { + "discovery": "Option to enable MQTT automatic discovery." } } }, @@ -123,6 +144,24 @@ "transport": "[%key:component::mqtt::config::step::broker::data::transport%]", "ws_headers": "[%key:component::mqtt::config::step::broker::data::ws_headers%]", "ws_path": "[%key:component::mqtt::config::step::broker::data::ws_path%]" + }, + "data_description": { + "broker": "[%key:component::mqtt::config::step::broker::data_description::broker%]", + "port": "[%key:component::mqtt::config::step::broker::data_description::port%]", + "username": "[%key:component::mqtt::config::step::broker::data_description::username%]", + "password": "[%key:component::mqtt::config::step::broker::data_description::password%]", + "advanced_options": "[%key:component::mqtt::config::step::broker::data_description::advanced_options%]", + "certificate": "[%key:component::mqtt::config::step::broker::data_description::certificate%]", + "client_id": "[%key:component::mqtt::config::step::broker::data_description::client_id%]", + "client_cert": "[%key:component::mqtt::config::step::broker::data_description::client_cert%]", + "client_key": "[%key:component::mqtt::config::step::broker::data_description::client_key%]", + "tls_insecure": "[%key:component::mqtt::config::step::broker::data_description::tls_insecure%]", + "protocol": "[%key:component::mqtt::config::step::broker::data_description::protocol%]", + "set_ca_cert": "[%key:component::mqtt::config::step::broker::data_description::set_ca_cert%]", + "set_client_cert": "[%key:component::mqtt::config::step::broker::data_description::set_client_cert%]", + "transport": "[%key:component::mqtt::config::step::broker::data_description::transport%]", + "ws_headers": "[%key:component::mqtt::config::step::broker::data_description::ws_headers%]", + "ws_path": "[%key:component::mqtt::config::step::broker::data_description::ws_path%]" } }, "options": { @@ -141,6 +180,20 @@ "will_payload": "Will message payload", "will_qos": "Will message QoS", "will_retain": "Will message retain" + }, + "data_description": { + "discovery": "Option to enable MQTT automatic discovery.", + "discovery_prefix": "The prefix of configuration topics the MQTT integration will subscribe to.", + "birth_enable": "When set, Home Assistant will publish an online message to your MQTT broker when MQTT is ready.", + "birth_topic": "The MQTT topic where Home Assistant will publish a `birth` message.", + "birth_payload": "The `birth` message that is published when MQTT is ready and connected.", + "birth_qos": "The quality of service of the `birth` message that is published when MQTT is ready and connected", + "birth_retain": "When set, Home Assistant will retain the `birth` message published to your MQTT broker.", + "will_enable": "When set, Home Assistant will ask your broker to publish a `will` message when MQTT is stopped or when it looses the connection to your broker.", + "will_topic": "The MQTT topic your MQTT broker will publish a `will` message to.", + "will_payload": "The message your MQTT broker `will` publish when the MQTT integration is stopped or when the connection is lost.", + "will_qos": "The quality of service of the `will` message that is published by your MQTT broker.", + "will_retain": "When set, your MQTT broker will retain the `will` message." } } }, @@ -214,7 +267,13 @@ }, "exceptions": { "invalid_platform_config": { - "message": "Reloading YAML config for manually configured MQTT `{domain}` failed. See logs for more details." + "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 6e364182cb02a9..f478ad712d77cc 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/mqtt/valve.py b/homeassistant/components/mqtt/valve.py new file mode 100644 index 00000000000000..9d167f42d120d4 --- /dev/null +++ b/homeassistant/components/mqtt/valve.py @@ -0,0 +1,447 @@ +"""Support for MQTT valve devices.""" +from __future__ import annotations + +from contextlib import suppress +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import valve +from homeassistant.components.valve import ( + DEVICE_CLASSES_SCHEMA, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_OPTIMISTIC, + CONF_VALUE_TEMPLATE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from . import subscription +from .config import MQTT_BASE_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_PAYLOAD_STOP, + CONF_POSITION_CLOSED, + CONF_POSITION_OPEN, + CONF_QOS, + CONF_RETAIN, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, + CONF_STATE_TOPIC, + DEFAULT_OPTIMISTIC, + DEFAULT_PAYLOAD_CLOSE, + DEFAULT_PAYLOAD_OPEN, + DEFAULT_POSITION_CLOSED, + DEFAULT_POSITION_OPEN, + DEFAULT_RETAIN, +) +from .debug_info import log_messages +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, + write_state_on_attr_change, +) +from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage +from .util import valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +CONF_REPORTS_POSITION = "reports_position" + +DEFAULT_NAME = "MQTT Valve" + +MQTT_VALVE_ATTRIBUTES_BLOCKED = frozenset( + { + valve.ATTR_CURRENT_POSITION, + } +) + +NO_POSITION_KEYS = ( + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_STATE_CLOSED, + CONF_STATE_OPEN, +) + +DEFAULTS = { + CONF_PAYLOAD_CLOSE: DEFAULT_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN: DEFAULT_PAYLOAD_OPEN, + CONF_STATE_OPEN: STATE_OPEN, + CONF_STATE_CLOSED: STATE_CLOSED, +} + +RESET_CLOSING_OPENING = "reset_opening_closing" + + +def _validate_and_add_defaults(config: ConfigType) -> ConfigType: + """Validate config options and set defaults.""" + if config[CONF_REPORTS_POSITION] and any(key in config for key in NO_POSITION_KEYS): + raise vol.Invalid( + "Options `payload_open`, `payload_close`, `state_open` and " + "`state_closed` are not allowed if the valve reports a position." + ) + return {**DEFAULTS, **config} + + +_PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PAYLOAD_CLOSE): vol.Any(cv.string, None), + vol.Optional(CONF_PAYLOAD_OPEN): vol.Any(cv.string, None), + vol.Optional(CONF_PAYLOAD_STOP): vol.Any(cv.string, None), + vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): int, + vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): int, + vol.Optional(CONF_REPORTS_POSITION, default=False): cv.boolean, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_STATE_CLOSED): cv.string, + vol.Optional(CONF_STATE_CLOSING, default=STATE_CLOSING): cv.string, + vol.Optional(CONF_STATE_OPEN): cv.string, + vol.Optional(CONF_STATE_OPENING, default=STATE_OPENING): cv.string, + vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +PLATFORM_SCHEMA_MODERN = vol.All(_PLATFORM_SCHEMA_BASE, _validate_and_add_defaults) + +DISCOVERY_SCHEMA = vol.All( + _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), + _validate_and_add_defaults, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT valve through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttValve, + valve.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttValve(MqttEntity, ValveEntity): + """Representation of a valve that can be controlled using MQTT.""" + + _attr_is_closed: bool | None = None + _attributes_extra_blocked: frozenset[str] = MQTT_VALVE_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format: str = valve.ENTITY_ID_FORMAT + _optimistic: bool + _range: tuple[int, int] + _tilt_optimistic: bool + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """Set up valve from config.""" + self._attr_reports_position = config[CONF_REPORTS_POSITION] + self._range = ( + self._config[CONF_POSITION_CLOSED] + 1, + self._config[CONF_POSITION_OPEN], + ) + no_state_topic = config.get(CONF_STATE_TOPIC) is None + self._optimistic = config[CONF_OPTIMISTIC] or no_state_topic + self._attr_assumed_state = self._optimistic + + template_config_attributes = { + "position_open": config[CONF_POSITION_OPEN], + "position_closed": config[CONF_POSITION_CLOSED], + } + + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), entity=self + ).async_render_with_possible_json_value + + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), entity=self + ).async_render + + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + config_attributes=template_config_attributes, + ).async_render_with_possible_json_value + + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + + supported_features = ValveEntityFeature(0) + if CONF_COMMAND_TOPIC in config: + if config[CONF_PAYLOAD_OPEN] is not None: + supported_features |= ValveEntityFeature.OPEN + if config[CONF_PAYLOAD_CLOSE] is not None: + supported_features |= ValveEntityFeature.CLOSE + + if config[CONF_REPORTS_POSITION]: + supported_features |= ValveEntityFeature.SET_POSITION + if config.get(CONF_PAYLOAD_STOP) is not None: + supported_features |= ValveEntityFeature.STOP + + self._attr_supported_features = supported_features + + @callback + def _update_state(self, state: str) -> None: + """Update the valve state properties.""" + self._attr_is_opening = state == STATE_OPENING + self._attr_is_closing = state == STATE_CLOSING + if self.reports_position: + return + self._attr_is_closed = state == STATE_CLOSED + + @callback + def _process_binary_valve_update( + self, msg: ReceiveMessage, state_payload: str + ) -> None: + """Process an update for a valve that does not report the position.""" + state: str | None = None + if state_payload == self._config[CONF_STATE_OPENING]: + state = STATE_OPENING + elif state_payload == self._config[CONF_STATE_CLOSING]: + state = STATE_CLOSING + elif state_payload == self._config[CONF_STATE_OPEN]: + state = STATE_OPEN + elif state_payload == self._config[CONF_STATE_CLOSED]: + state = STATE_CLOSED + if state is None: + _LOGGER.warning( + "Payload received on topic '%s' is not one of " + "[open, closed, opening, closing], got: %s", + msg.topic, + state_payload, + ) + return + self._update_state(state) + + @callback + def _process_position_valve_update( + self, msg: ReceiveMessage, position_payload: str, state_payload: str + ) -> None: + """Process an update for a valve that reports the position.""" + state: str | None = None + position_set: bool = False + if state_payload == self._config[CONF_STATE_OPENING]: + state = STATE_OPENING + elif state_payload == self._config[CONF_STATE_CLOSING]: + state = STATE_CLOSING + if state is None or position_payload != state_payload: + try: + percentage_payload = ranged_value_to_percentage( + self._range, float(position_payload) + ) + except ValueError: + _LOGGER.warning( + "Ignoring non numeric payload '%s' received on topic '%s'", + position_payload, + msg.topic, + ) + else: + percentage_payload = min(max(percentage_payload, 0), 100) + self._attr_current_valve_position = percentage_payload + # Reset closing and opening if the valve is fully opened or fully closed + if state is None and percentage_payload in (0, 100): + state = RESET_CLOSING_OPENING + position_set = True + if state_payload and state is None and not position_set: + _LOGGER.warning( + "Payload received on topic '%s' is not one of " + "[opening, closing], got: %s", + msg.topic, + state_payload, + ) + return + if state is None: + return + self._update_state(state) + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + topics = {} + + @callback + @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_current_valve_position", + "_attr_is_closed", + "_attr_is_closing", + "_attr_is_opening", + }, + ) + def state_message_received(msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + payload = self._value_template(msg.payload) + payload_dict: Any = None + position_payload: Any = payload + state_payload: Any = payload + + if not payload: + _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) + return + + with suppress(*JSON_DECODE_EXCEPTIONS): + payload_dict = json_loads(payload) + if isinstance(payload_dict, dict): + if self.reports_position and "position" not in payload_dict: + _LOGGER.warning( + "Missing required `position` attribute in json payload " + "on topic '%s', got: %s", + msg.topic, + payload, + ) + return + if not self.reports_position and "state" not in payload_dict: + _LOGGER.warning( + "Missing required `state` attribute in json payload " + " on topic '%s', got: %s", + msg.topic, + payload, + ) + return + position_payload = payload_dict.get("position") + state_payload = payload_dict.get("state") + + if self._config[CONF_REPORTS_POSITION]: + self._process_position_valve_update( + msg, position_payload, state_payload + ) + else: + self._process_binary_valve_update(msg, state_payload) + + if self._config.get(CONF_STATE_TOPIC): + topics["state_topic"] = { + "topic": self._config.get(CONF_STATE_TOPIC), + "msg_callback": state_message_received, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + async def async_open_valve(self) -> None: + """Move the valve up. + + This method is a coroutine. + """ + payload = self._command_template( + self._config.get(CONF_PAYLOAD_OPEN, DEFAULT_PAYLOAD_OPEN) + ) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + # Optimistically assume that valve has changed state. + self._update_state(STATE_OPEN) + self.async_write_ha_state() + + async def async_close_valve(self) -> None: + """Move the valve down. + + This method is a coroutine. + """ + payload = self._command_template( + self._config.get(CONF_PAYLOAD_CLOSE, DEFAULT_PAYLOAD_CLOSE) + ) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + # Optimistically assume that valve has changed state. + self._update_state(STATE_CLOSED) + self.async_write_ha_state() + + async def async_stop_valve(self) -> None: + """Stop valve positioning. + + This method is a coroutine. + """ + payload = self._command_template(self._config[CONF_PAYLOAD_STOP]) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + percentage_position = position + scaled_position = round( + percentage_to_ranged_value(self._range, percentage_position) + ) + variables = { + "position": percentage_position, + "position_open": self._config[CONF_POSITION_OPEN], + "position_closed": self._config[CONF_POSITION_CLOSED], + } + rendered_position = self._command_template(scaled_position, variables=variables) + + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + rendered_position, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + self._update_state( + STATE_CLOSED + if percentage_position == self._config[CONF_POSITION_CLOSED] + else STATE_OPEN + ) + self._attr_current_valve_position = percentage_position + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 0ccd2dbc47d3ff..a2cf2e511a0b7a 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -186,6 +186,8 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): _default_name = DEFAULT_NAME _entity_id_format = water_heater.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED + _attr_target_temperature_low: float | None = None + _attr_target_temperature_high: float | None = None @staticmethod def config_schema() -> vol.Schema: diff --git a/homeassistant/components/mutesync/strings.json b/homeassistant/components/mutesync/strings.json index 2a3cca666ee504..b082638489976b 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/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index 2b4edd992213e2..b70a7fc8d5582b 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -21,7 +21,7 @@ from .helpers import on_unload -@dataclass +@dataclass(frozen=True) class MySensorsBinarySensorDescription(BinarySensorEntityDescription): """Describe a MySensors binary sensor entity.""" diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index 8011bfcb1552f1..fdf056c6c06909 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -18,6 +18,7 @@ valid_subscribe_topic, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector @@ -25,7 +26,6 @@ from .const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index a5c82c32b558a9..0a4b4c090eff05 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -11,7 +11,6 @@ ATTR_NODE_ID: Final = "node_id" CONF_BAUD_RATE: Final = "baud_rate" -CONF_DEVICE: Final = "device" CONF_PERSISTENCE_FILE: Final = "persistence_file" CONF_RETAIN: Final = "retain" CONF_TCP_PORT: Final = "tcp_port" diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 6d7decf14f417d..c70ef1f89ed2a1 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -8,7 +8,13 @@ from mysensors import BaseAsyncGateway, Sensor from mysensors.sensor import ChildSensor -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON, Platform +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + CONF_DEVICE, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -17,7 +23,6 @@ from .const import ( CHILD_CALLBACK, - CONF_DEVICE, DOMAIN, NODE_CALLBACK, PLATFORM_TYPES, diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 590ad41d6a277c..0818d68de2bac1 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -18,14 +18,13 @@ ReceivePayloadType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py index 606a6275acf3c3..4551c9ebbeccda 100644 --- a/homeassistant/components/mystrom/sensor.py +++ b/homeassistant/components/mystrom/sensor.py @@ -21,7 +21,7 @@ from .const import DOMAIN, MANUFACTURER -@dataclass +@dataclass(frozen=True) class MyStromSwitchSensorEntityDescription(SensorEntityDescription): """Class describing mystrom switch sensor entities.""" diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json index a485a58f5a6621..9ebd1c36df002b 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/nam/manifest.json b/homeassistant/components/nam/manifest.json index 8d4396d5d8004e..a4ef9af9aee4cc 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.1"], + "requirements": ["nettigo-air-monitor==2.2.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 3c0b8bc9ba4784..5b3c6517f645bd 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -74,14 +74,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class NAMSensorRequiredKeysMixin: """Class for NAM entity required keys.""" value: Callable[[NAMSensors], StateType | datetime] -@dataclass +@dataclass(frozen=True) class NAMSensorEntityDescription(SensorEntityDescription, NAMSensorRequiredKeysMixin): """NAM sensor entity description.""" diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index e443a398984493..83a40d87f76c2d 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 80eb2ded7d0e89..13e7c9a11a32ac 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 3dcceecd1e313c..6a442e7c353b87 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -16,8 +16,8 @@ "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]" + "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 a54ac82a9a7ae0..35e1cc68165bfd 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -52,8 +52,8 @@ "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]" + "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/__init__.py b/homeassistant/components/netatmo/__init__.py index ddd2fc61ed72aa..4535805915b1d3 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -8,7 +8,6 @@ import aiohttp import pyatmo -from pyatmo.const import ALL_SCOPES as NETATMO_SCOPES import voluptuous as vol from homeassistant.components import cloud @@ -143,7 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await session.async_ensure_token_valid() except aiohttp.ClientResponseError as ex: - _LOGGER.debug("API error: %s (%s)", ex.status, ex.message) + _LOGGER.warning("API error: %s (%s)", ex.status, ex.message) if ex.status in ( HTTPStatus.BAD_REQUEST, HTTPStatus.UNAUTHORIZED, @@ -152,19 +151,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex raise ConfigEntryNotReady from ex - if entry.data["auth_implementation"] == cloud.DOMAIN: - required_scopes = { - scope - for scope in NETATMO_SCOPES - if scope not in ("access_doorbell", "read_doorbell") - } - else: - required_scopes = set(NETATMO_SCOPES) - - if not (set(session.token["scope"]) & required_scopes): - _LOGGER.debug( + required_scopes = api.get_api_scopes(entry.data["auth_implementation"]) + if not (set(session.token["scope"]) & set(required_scopes)): + _LOGGER.warning( "Session is missing scopes: %s", - required_scopes - set(session.token["scope"]), + set(required_scopes) - set(session.token["scope"]), ) raise ConfigEntryAuthFailed("Token scope not valid, trigger renewal") diff --git a/homeassistant/components/netatmo/api.py b/homeassistant/components/netatmo/api.py index 0b36745338ef31..7605689b3f5cab 100644 --- a/homeassistant/components/netatmo/api.py +++ b/homeassistant/components/netatmo/api.py @@ -1,11 +1,29 @@ """API for Netatmo bound to HASS OAuth.""" +from collections.abc import Iterable from typing import cast from aiohttp import ClientSession import pyatmo +from homeassistant.components import cloud from homeassistant.helpers import config_entry_oauth2_flow +from .const import API_SCOPES_EXCLUDED_FROM_CLOUD + + +def get_api_scopes(auth_implementation: str) -> Iterable[str]: + """Return the Netatmo API scopes based on the auth implementation.""" + + if auth_implementation == cloud.DOMAIN: + return set( + { + scope + for scope in pyatmo.const.ALL_SCOPES + if scope not in API_SCOPES_EXCLUDED_FROM_CLOUD + } + ) + return sorted(pyatmo.const.ALL_SCOPES) + class AsyncConfigEntryNetatmoAuth(pyatmo.AbstractAsyncAuth): """Provide Netatmo authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index a14cadf45c43b5..5a05818d3f2793 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -39,6 +39,8 @@ ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, + ATTR_TARGET_TEMPERATURE, + ATTR_TIME_PERIOD, CONF_URL_ENERGY, DATA_SCHEDULES, DOMAIN, @@ -47,8 +49,11 @@ 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 @@ -143,6 +148,34 @@ def _create_entity(netatmo_device: NetatmoRoom) -> None: }, "_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): @@ -441,12 +474,48 @@ async def _async_service_set_preset_mode_with_end_datetime( 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/config_flow.py b/homeassistant/components/netatmo/config_flow.py index b4e6d83853752d..bae81a7762f939 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -6,7 +6,6 @@ from typing import Any import uuid -from pyatmo.const import ALL_SCOPES import voluptuous as vol from homeassistant import config_entries @@ -15,6 +14,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from .api import get_api_scopes from .const import ( CONF_AREA_NAME, CONF_LAT_NE, @@ -53,13 +53,7 @@ def logger(self) -> logging.Logger: @property def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" - exclude = [] - if self.flow_impl.name == "Home Assistant Cloud": - exclude = ["access_doorbell", "read_doorbell"] - - scopes = [scope for scope in ALL_SCOPES if scope not in exclude] - scopes.sort() - + scopes = get_api_scopes(self.flow_impl.domain) return {"scope": " ".join(scopes)} async def async_step_user(self, user_input: dict | None = None) -> FlowResult: diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 9e7ac33c8b614d..3fe456dd65734c 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -30,6 +30,13 @@ DATA_HANDLER = "netatmo_data_handler" SIGNAL_NAME = "signal_name" +API_SCOPES_EXCLUDED_FROM_CLOUD = [ + "access_doorbell", + "read_doorbell", + "read_mhs1", + "write_mhs1", +] + NETATMO_CREATE_BATTERY = "netatmo_create_battery" NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" @@ -82,12 +89,17 @@ 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/light.py b/homeassistant/components/netatmo/light.py index e3bd8952b555be..b796372fc20c60 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -186,11 +186,6 @@ def __init__( ] ) - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._dimmer.on is True - async def async_turn_on(self, **kwargs: Any) -> None: """Turn light on.""" if ATTR_BRIGHTNESS in kwargs: @@ -211,6 +206,8 @@ async def async_turn_off(self, **kwargs: Any) -> None: @callback def async_update_callback(self) -> None: """Update the entity's state.""" + self._attr_is_on = self._dimmer.on is True + if self._dimmer.brightness is not None: # Netatmo uses a range of [0, 100] to control brightness self._attr_brightness = round((self._dimmer.brightness / 100) * 255) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index d031632ed750fa..aee63e60016f06 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -7,10 +7,10 @@ "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", "loggers": ["pyatmo"], - "requirements": ["pyatmo==7.6.0"] + "requirements": ["pyatmo==8.0.2"] } diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 10114a75f63de4..692a1a806eab0f 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -70,14 +70,14 @@ ) -@dataclass +@dataclass(frozen=True) class NetatmoRequiredKeysMixin: """Mixin for required keys.""" netatmo_name: str -@dataclass +@dataclass(frozen=True) class NetatmoSensorEntityDescription(SensorEntityDescription, NetatmoRequiredKeysMixin): """Describes Netatmo sensor entity.""" @@ -447,17 +447,16 @@ def __init__( } ) - @property - def available(self) -> bool: - """Return entity availability.""" - return self.state is not None - @callback def async_update_callback(self) -> None: """Update the entity's state.""" if ( - state := getattr(self._module, self.entity_description.netatmo_name) - ) is None: + not self._module.reachable + or (state := getattr(self._module, self.entity_description.netatmo_name)) + is None + ): + if self.available: + self._attr_available = False return if self.entity_description.netatmo_name in { @@ -475,6 +474,7 @@ def async_update_callback(self) -> None: else: self._attr_native_value = state + self._attr_available = True self.async_write_ha_state() @@ -519,7 +519,6 @@ def async_update_callback(self) -> None: if not self._module.reachable: if self.available: self._attr_available = False - self._attr_native_value = None return self._attr_available = True @@ -565,9 +564,15 @@ def __init__( @callback def async_update_callback(self) -> None: """Update the entity's state.""" + if not self._module.reachable: + if self.available: + self._attr_available = False + return + if (state := getattr(self._module, self.entity_description.key)) is None: return + self._attr_available = True self._attr_native_value = state self.async_write_ha_state() @@ -777,7 +782,6 @@ def async_update_callback(self) -> None: self.entity_description.key, self._area_name, ) - self._attr_native_value = None self._attr_available = False return diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index 228f84f175d774..cab0528199dd46 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 99f780dbe3ec65..e504b27b599d80 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -17,8 +17,8 @@ "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]" + "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%]" @@ -121,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": { @@ -129,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/button.py b/homeassistant/components/netgear/button.py index f3283f8d7b53f4..6ec988edbe1f1f 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -19,14 +19,14 @@ from .router import NetgearRouter -@dataclass +@dataclass(frozen=True) class NetgearButtonEntityDescriptionRequired: """Required attributes of NetgearButtonEntityDescription.""" action: Callable[[NetgearRouter], Callable[[], Coroutine[Any, Any, None]]] -@dataclass +@dataclass(frozen=True) class NetgearButtonEntityDescription( ButtonEntityDescription, NetgearButtonEntityDescriptionRequired ): diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 6e7771d44cb968..897fe9da30c5af 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -77,7 +77,7 @@ } -@dataclass +@dataclass(frozen=True) class NetgearSensorEntityDescription(SensorEntityDescription): """Class describing Netgear sensor entities.""" diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 6b4883b8ce31fc..9f3b1aeec9e459 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/switch.py b/homeassistant/components/netgear/switch.py index a4548da16a4c56..4be13a0f32c688 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -32,7 +32,7 @@ ] -@dataclass +@dataclass(frozen=True) class NetgearSwitchEntityDescriptionRequired: """Required attributes of NetgearSwitchEntityDescription.""" @@ -40,7 +40,7 @@ class NetgearSwitchEntityDescriptionRequired: action: Callable[[NetgearRouter], bool] -@dataclass +@dataclass(frozen=True) class NetgearSwitchEntityDescription( SwitchEntityDescription, NetgearSwitchEntityDescriptionRequired ): diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index d6ce3cb09944d8..9faa2f361b9556 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -1,12 +1,12 @@ """Support for Netgear LTE modems.""" -import asyncio from datetime import timedelta -import aiohttp +from aiohttp.cookiejar import CookieJar import attr import eternalegypt import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_MONITORED_CONDITIONS, @@ -16,14 +16,15 @@ EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from . import sensor_types from .const import ( ATTR_FROM, ATTR_HOST, @@ -32,6 +33,7 @@ CONF_BINARY_SENSOR, CONF_NOTIFY, CONF_SENSOR, + DATA_HASS_CONFIG, DISPATCHER_NETGEAR_LTE, DOMAIN, LOGGER, @@ -42,6 +44,28 @@ EVENT_SMS = "netgear_lte_sms" +ALL_SENSORS = [ + "sms", + "sms_total", + "usage", + "radio_quality", + "rx_level", + "tx_level", + "upstream", + "connection_text", + "connection_type", + "current_ps_service_type", + "register_network_display", + "current_band", + "cell_id", +] + +ALL_BINARY_SENSORS = [ + "roaming", + "wire_connected", + "mobile_connected", +] + NOTIFY_SCHEMA = vol.Schema( { @@ -52,17 +76,17 @@ SENSOR_SCHEMA = vol.Schema( { - vol.Optional( - CONF_MONITORED_CONDITIONS, default=sensor_types.DEFAULT_SENSORS - ): vol.All(cv.ensure_list, [vol.In(sensor_types.ALL_SENSORS)]) + vol.Optional(CONF_MONITORED_CONDITIONS, default=["usage"]): vol.All( + cv.ensure_list, [vol.In(ALL_SENSORS)] + ) } ) BINARY_SENSOR_SCHEMA = vol.Schema( { - vol.Optional( - CONF_MONITORED_CONDITIONS, default=sensor_types.DEFAULT_BINARY_SENSORS - ): vol.All(cv.ensure_list, [vol.In(sensor_types.ALL_BINARY_SENSORS)]) + vol.Optional(CONF_MONITORED_CONDITIONS, default=["mobile_connected"]): vol.All( + cv.ensure_list, [vol.In(ALL_BINARY_SENSORS)] + ) } ) @@ -90,6 +114,12 @@ extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.NOTIFY, + Platform.SENSOR, +] + @attr.s class ModemData: @@ -137,90 +167,108 @@ def get_modem_data(self, config): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Netgear LTE component.""" - if DOMAIN not in hass.data: - websession = async_create_clientsession( - hass, cookie_jar=aiohttp.CookieJar(unsafe=True) - ) - hass.data[DOMAIN] = LTEData(websession) + hass.data[DATA_HASS_CONFIG] = config - await async_setup_services(hass) + if lte_config := config.get(DOMAIN): + hass.async_create_task(import_yaml(hass, lte_config)) - netgear_lte_config = config[DOMAIN] + return True - # Set up each modem - tasks = [ - hass.async_create_task(_setup_lte(hass, lte_conf)) - for lte_conf in netgear_lte_config - ] - await asyncio.wait(tasks) - - # Load platforms for each modem - for lte_conf in netgear_lte_config: - # Notify - for notify_conf in lte_conf[CONF_NOTIFY]: - discovery_info = { - CONF_HOST: lte_conf[CONF_HOST], - CONF_NAME: notify_conf.get(CONF_NAME), - CONF_NOTIFY: notify_conf, - } - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.NOTIFY, DOMAIN, discovery_info, config - ) - ) - # Sensor - sensor_conf = lte_conf[CONF_SENSOR] - discovery_info = {CONF_HOST: lte_conf[CONF_HOST], CONF_SENSOR: sensor_conf} - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.SENSOR, DOMAIN, discovery_info, config - ) +async def import_yaml(hass: HomeAssistant, lte_config: ConfigType) -> None: + """Import yaml if we can connect. Create appropriate issue registry entries.""" + for entry in lte_config: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry ) - - # Binary Sensor - binary_sensor_conf = lte_conf[CONF_BINARY_SENSOR] - discovery_info = { - CONF_HOST: lte_conf[CONF_HOST], - CONF_BINARY_SENSOR: binary_sensor_conf, - } - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.BINARY_SENSOR, DOMAIN, discovery_info, config + if result.get("reason") == "cannot_connect": + async_create_issue( + hass, + DOMAIN, + "import_failure", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="import_failure", + ) + else: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Netgear LTE", + }, ) - ) - - return True -async def _setup_lte(hass, lte_config): - """Set up a Netgear LTE modem.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Netgear LTE from a config entry.""" + host = entry.data[CONF_HOST] + password = entry.data[CONF_PASSWORD] - host = lte_config[CONF_HOST] - password = lte_config[CONF_PASSWORD] + if DOMAIN not in hass.data: + websession = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) - websession = hass.data[DOMAIN].websession - modem = eternalegypt.Modem(hostname=host, websession=websession) + hass.data[DOMAIN] = LTEData(websession) + modem = eternalegypt.Modem(hostname=host, websession=hass.data[DOMAIN].websession) modem_data = ModemData(hass, host, modem) - try: - await _login(hass, modem_data, password) - except eternalegypt.Error: - retry_task = hass.loop.create_task(_retry_login(hass, modem_data, password)) + await _login(hass, modem_data, password) + + async def _update(now): + """Periodic update.""" + await modem_data.async_update() + + update_unsub = async_track_time_interval(hass, _update, SCAN_INTERVAL) + + async def cleanup(event: Event | None = None) -> None: + """Clean up resources.""" + update_unsub() + await modem.logout() + if DOMAIN in hass.data: + del hass.data[DOMAIN].modem_data[modem_data.host] + + entry.async_on_unload(cleanup) + entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)) + + await async_setup_services(hass) + + _legacy_task(hass, entry) - @callback - def cleanup_retry(event): - """Clean up retry task resources.""" - if not retry_task.done(): - retry_task.cancel() + await hass.config_entries.async_forward_entry_setups( + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] + ) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) + 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) + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + hass.data.pop(DOMAIN) + return unload_ok -async def _login(hass, modem_data, password): + +async def _login(hass: HomeAssistant, modem_data: ModemData, password: str) -> None: """Log in and complete setup.""" - await modem_data.modem.login(password=password) + try: + await modem_data.modem.login(password=password) + except eternalegypt.Error as ex: + raise ConfigEntryNotReady("Cannot connect/authenticate") from ex def fire_sms_event(sms): """Send an SMS event.""" @@ -237,33 +285,63 @@ def fire_sms_event(sms): await modem_data.async_update() hass.data[DOMAIN].modem_data[modem_data.host] = modem_data - async def _update(now): - """Periodic update.""" - await modem_data.async_update() - - update_unsub = async_track_time_interval(hass, _update, SCAN_INTERVAL) - async def cleanup(event): - """Clean up resources.""" - update_unsub() - await modem_data.modem.logout() - del hass.data[DOMAIN].modem_data[modem_data.host] - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) - - -async def _retry_login(hass, modem_data, password): - """Sleep and retry setup.""" - - LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) - - modem_data.connected = False - delay = 15 - - while not modem_data.connected: - await asyncio.sleep(delay) - - try: - await _login(hass, modem_data, password) - except eternalegypt.Error: - delay = min(2 * delay, 300) +def _legacy_task(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Create notify service and add a repair issue when appropriate.""" + # Discovery can happen up to 2 times for notify depending on existing yaml config + # One for the name of the config entry, allows the user to customize the name + # One for each notify described in the yaml config which goes away with config flow + # One for the default if the user never specified one + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_HOST: entry.data[CONF_HOST], CONF_NAME: entry.title}, + hass.data[DATA_HASS_CONFIG], + ) + ) + if not (lte_configs := hass.data[DATA_HASS_CONFIG].get(DOMAIN, [])): + return + async_create_issue( + hass, + DOMAIN, + "deprecated_notify", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_notify", + translation_placeholders={ + "name": f"{Platform.NOTIFY}.{entry.title.lower().replace(' ', '_')}" + }, + ) + + for lte_config in lte_configs: + if lte_config[CONF_HOST] == entry.data[CONF_HOST]: + if not lte_config[CONF_NOTIFY]: + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_HOST: entry.data[CONF_HOST], CONF_NAME: DOMAIN}, + hass.data[DATA_HASS_CONFIG], + ) + ) + break + for notify_conf in lte_config[CONF_NOTIFY]: + discovery_info = { + CONF_HOST: lte_config[CONF_HOST], + CONF_NAME: notify_conf.get(CONF_NAME), + CONF_NOTIFY: notify_conf, + } + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + discovery_info, + hass.data[DATA_HASS_CONFIG], + ) + ) + break diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index add59096024ce2..2830c551b80781 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -1,52 +1,56 @@ """Support for Netgear LTE binary sensors.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_BINARY_SENSOR, DOMAIN +from .const import DOMAIN from .entity import LTEEntity -from .sensor_types import BINARY_SENSOR_CLASSES - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="roaming", + translation_key="roaming", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="wire_connected", + translation_key="wire_connected", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + BinarySensorEntityDescription( + key="mobile_connected", + translation_key="mobile_connected", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up Netgear LTE binary sensor devices.""" - if discovery_info is None: - return - - modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) - - if not modem_data or not modem_data.data: - raise PlatformNotReady - - binary_sensor_conf = discovery_info[CONF_BINARY_SENSOR] - monitored_conditions = binary_sensor_conf[CONF_MONITORED_CONDITIONS] + """Set up the Netgear LTE binary sensor.""" + modem_data = hass.data[DOMAIN].get_modem_data(entry.data) - binary_sensors = [] - for sensor_type in monitored_conditions: - binary_sensors.append(LTEBinarySensor(modem_data, sensor_type)) + async_add_entities( + NetgearLTEBinarySensor(entry, modem_data, sensor) for sensor in BINARY_SENSORS + ) - async_add_entities(binary_sensors) - -class LTEBinarySensor(LTEEntity, BinarySensorEntity): +class NetgearLTEBinarySensor(LTEEntity, BinarySensorEntity): """Netgear LTE binary sensor entity.""" @property def is_on(self): """Return true if the binary sensor is on.""" - return getattr(self.modem_data.data, self.sensor_type) - - @property - def device_class(self): - """Return the class of binary sensor.""" - return BINARY_SENSOR_CLASSES[self.sensor_type] + return getattr(self.modem_data.data, self.entity_description.key) diff --git a/homeassistant/components/netgear_lte/config_flow.py b/homeassistant/components/netgear_lte/config_flow.py new file mode 100644 index 00000000000000..a3a56bab03bd93 --- /dev/null +++ b/homeassistant/components/netgear_lte/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for Netgear LTE integration.""" +from __future__ import annotations + +from typing import Any + +from aiohttp.cookiejar import CookieJar +from eternalegypt import Error, Modem +from eternalegypt.eternalegypt import Information +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import DEFAULT_HOST, DOMAIN, LOGGER, MANUFACTURER + + +class NetgearLTEFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Netgear LTE.""" + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a configuration from config.yaml.""" + host = config[CONF_HOST] + password = config[CONF_PASSWORD] + self._async_abort_entries_match({CONF_HOST: host}) + try: + info = await self._async_validate_input(host, password) + except InputValidationError: + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {info.items['general.devicename']}", + data={CONF_HOST: host, CONF_PASSWORD: password}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input: + host = user_input[CONF_HOST] + password = user_input[CONF_PASSWORD] + + try: + info = await self._async_validate_input(host, password) + except InputValidationError as ex: + errors["base"] = ex.base + else: + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {info.items['general.devicename']}", + data={CONF_HOST: host, CONF_PASSWORD: password}, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PASSWORD): str, + } + ), + user_input or {CONF_HOST: DEFAULT_HOST}, + ), + errors=errors, + ) + + async def _async_validate_input(self, host: str, password: str) -> Information: + """Validate login credentials.""" + websession = async_create_clientsession( + self.hass, cookie_jar=CookieJar(unsafe=True) + ) + + modem = Modem( + hostname=host, + password=password, + websession=websession, + ) + try: + await modem.login() + info = await modem.information() + except Error as ex: + raise InputValidationError("cannot_connect") from ex + except Exception as ex: + LOGGER.exception("Unexpected exception") + raise InputValidationError("unknown") from ex + await modem.logout() + return info + + +class InputValidationError(exceptions.HomeAssistantError): + """Error to indicate we cannot proceed due to invalid input.""" + + def __init__(self, base: str) -> None: + """Initialize with error base.""" + super().__init__() + self.base = base diff --git a/homeassistant/components/netgear_lte/const.py b/homeassistant/components/netgear_lte/const.py index 12c8f06b695c03..b47218bf4e107a 100644 --- a/homeassistant/components/netgear_lte/const.py +++ b/homeassistant/components/netgear_lte/const.py @@ -14,9 +14,14 @@ CONF_NOTIFY: Final = "notify" CONF_SENSOR: Final = "sensor" +DATA_HASS_CONFIG = "netgear_lte_hass_config" +# https://kb.netgear.com/31160/How-do-I-change-my-4G-LTE-Modem-s-IP-address-range +DEFAULT_HOST = "192.168.5.1" DISPATCHER_NETGEAR_LTE = "netgear_lte_update" DOMAIN: Final = "netgear_lte" FAILOVER_MODES = ["auto", "wire", "mobile"] LOGGER = logging.getLogger(__package__) + +MANUFACTURER: Final = "Netgear" diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py index 33e0aaab749a2d..0ec16ceff9d23b 100644 --- a/homeassistant/components/netgear_lte/entity.py +++ b/homeassistant/components/netgear_lte/entity.py @@ -1,27 +1,40 @@ """Entity representing a Netgear LTE entity.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from . import ModemData -from .const import DISPATCHER_NETGEAR_LTE +from .const import DISPATCHER_NETGEAR_LTE, DOMAIN, MANUFACTURER class LTEEntity(Entity): """Base LTE entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, + config_entry: ConfigEntry, modem_data: ModemData, - sensor_type: str, + description: EntityDescription, ) -> None: """Initialize a Netgear LTE entity.""" + self.entity_description = description self.modem_data = modem_data - self.sensor_type = sensor_type - self._attr_name = f"Netgear LTE {sensor_type}" - self._attr_unique_id = f"{sensor_type}_{modem_data.data.serial_number}" + self._attr_unique_id = f"{description.key}_{modem_data.data.serial_number}" + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{config_entry.data[CONF_HOST]}", + identifiers={(DOMAIN, modem_data.data.serial_number)}, + manufacturer=MANUFACTURER, + model=modem_data.data.items["general.model"], + serial_number=modem_data.data.serial_number, + sw_version=modem_data.data.items["general.fwversion"], + hw_version=modem_data.data.items["general.hwversion"], + ) async def async_added_to_hass(self) -> None: """Register callback.""" diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index c9a5245da4155b..bc103018359cb3 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -2,6 +2,7 @@ "domain": "netgear_lte", "name": "NETGEAR LTE", "codeowners": ["@tkdrob"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "iot_class": "local_polling", "loggers": ["eternalegypt"], diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py index c21b56799eb758..ddc5e93677cb77 100644 --- a/homeassistant/components/netgear_lte/notify.py +++ b/homeassistant/components/netgear_lte/notify.py @@ -38,8 +38,8 @@ async def async_send_message(self, message="", **kwargs): if not modem_data: LOGGER.error("Modem not ready") return - - targets = kwargs.get(ATTR_TARGET, self.config[CONF_NOTIFY][CONF_RECIPIENT]) + if not (targets := kwargs.get(ATTR_TARGET)): + targets = self.config[CONF_NOTIFY][CONF_RECIPIENT] if not targets: LOGGER.warning("No recipients") return diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 4ca127e5724175..4e978a2f964228 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -1,92 +1,156 @@ """Support for Netgear LTE sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfInformation, +) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import StateType -from .const import CONF_SENSOR, DOMAIN +from . import ModemData +from .const import DOMAIN from .entity import LTEEntity -from .sensor_types import SENSOR_SMS, SENSOR_SMS_TOTAL, SENSOR_UNITS, SENSOR_USAGE -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +@dataclass(frozen=True, kw_only=True) +class NetgearLTESensorEntityDescription(SensorEntityDescription): + """Class describing Netgear LTE entities.""" + + value_fn: Callable[[ModemData], StateType] | None = None + + +SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( + NetgearLTESensorEntityDescription( + key="sms", + translation_key="sms", + icon="mdi:message-processing", + native_unit_of_measurement="unread", + value_fn=lambda modem_data: sum(1 for x in modem_data.data.sms if x.unread), + ), + NetgearLTESensorEntityDescription( + key="sms_total", + translation_key="sms_total", + icon="mdi:message-processing", + native_unit_of_measurement="messages", + value_fn=lambda modem_data: len(modem_data.data.sms), + ), + NetgearLTESensorEntityDescription( + key="usage", + translation_key="usage", + device_class=SensorDeviceClass.DATA_SIZE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=1, + value_fn=lambda modem_data: modem_data.data.usage, + ), + NetgearLTESensorEntityDescription( + key="radio_quality", + translation_key="radio_quality", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), + NetgearLTESensorEntityDescription( + key="rx_level", + translation_key="rx_level", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ), + NetgearLTESensorEntityDescription( + key="tx_level", + translation_key="tx_level", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ), + NetgearLTESensorEntityDescription( + key="upstream", + translation_key="upstream", + entity_registry_enabled_default=False, + icon="mdi:ip-network", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NetgearLTESensorEntityDescription( + key="connection_text", + translation_key="connection_text", + entity_registry_enabled_default=False, + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NetgearLTESensorEntityDescription( + key="connection_type", + translation_key="connection_type", + entity_registry_enabled_default=False, + icon="mdi:ip", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NetgearLTESensorEntityDescription( + key="current_ps_service_type", + translation_key="service_type", + entity_registry_enabled_default=False, + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NetgearLTESensorEntityDescription( + key="register_network_display", + translation_key="register_network_display", + entity_registry_enabled_default=False, + icon="mdi:web", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NetgearLTESensorEntityDescription( + key="current_band", + translation_key="band", + entity_registry_enabled_default=False, + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NetgearLTESensorEntityDescription( + key="cell_id", + translation_key="cell_id", + entity_registry_enabled_default=False, + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up Netgear LTE sensor devices.""" - if discovery_info is None: - return + """Set up the Netgear LTE sensor.""" + modem_data = hass.data[DOMAIN].get_modem_data(entry.data) - modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) + async_add_entities( + NetgearLTESensor(entry, modem_data, sensor) for sensor in SENSORS + ) - if not modem_data or not modem_data.data: - raise PlatformNotReady - sensor_conf = discovery_info[CONF_SENSOR] - monitored_conditions = sensor_conf[CONF_MONITORED_CONDITIONS] - - sensors: list[SensorEntity] = [] - for sensor_type in monitored_conditions: - if sensor_type == SENSOR_SMS: - sensors.append(SMSUnreadSensor(modem_data, sensor_type)) - elif sensor_type == SENSOR_SMS_TOTAL: - sensors.append(SMSTotalSensor(modem_data, sensor_type)) - elif sensor_type == SENSOR_USAGE: - sensors.append(UsageSensor(modem_data, sensor_type)) - else: - sensors.append(GenericSensor(modem_data, sensor_type)) - - async_add_entities(sensors) - - -class LTESensor(LTEEntity, SensorEntity): +class NetgearLTESensor(LTEEntity, SensorEntity): """Base LTE sensor entity.""" - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSOR_UNITS[self.sensor_type] - - -class SMSUnreadSensor(LTESensor): - """Unread SMS sensor entity.""" - - @property - def native_value(self): - """Return the state of the sensor.""" - return sum(1 for x in self.modem_data.data.sms if x.unread) - - -class SMSTotalSensor(LTESensor): - """Total SMS sensor entity.""" - - @property - def native_value(self): - """Return the state of the sensor.""" - return len(self.modem_data.data.sms) - - -class UsageSensor(LTESensor): - """Data usage sensor entity.""" - - _attr_device_class = SensorDeviceClass.DATA_SIZE - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - return round(self.modem_data.data.usage / 1024**2, 1) - - -class GenericSensor(LTESensor): - """Sensor entity with raw state.""" + entity_description: NetgearLTESensorEntityDescription @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" - return getattr(self.modem_data.data, self.sensor_type) + if self.entity_description.value_fn is not None: + return self.entity_description.value_fn(self.modem_data) + return getattr(self.modem_data.data, self.entity_description.key) diff --git a/homeassistant/components/netgear_lte/sensor_types.py b/homeassistant/components/netgear_lte/sensor_types.py deleted file mode 100644 index 01aa267e95363b..00000000000000 --- a/homeassistant/components/netgear_lte/sensor_types.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Define possible sensor types.""" - -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ( - PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - UnitOfInformation, -) - -SENSOR_SMS = "sms" -SENSOR_SMS_TOTAL = "sms_total" -SENSOR_USAGE = "usage" - -SENSOR_UNITS = { - SENSOR_SMS: "unread", - SENSOR_SMS_TOTAL: "messages", - SENSOR_USAGE: UnitOfInformation.MEBIBYTES, - "radio_quality": PERCENTAGE, - "rx_level": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - "tx_level": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - "upstream": None, - "connection_text": None, - "connection_type": None, - "current_ps_service_type": None, - "register_network_display": None, - "current_band": None, - "cell_id": None, -} - -BINARY_SENSOR_MOBILE_CONNECTED = "mobile_connected" - -BINARY_SENSOR_CLASSES = { - "roaming": None, - "wire_connected": BinarySensorDeviceClass.CONNECTIVITY, - BINARY_SENSOR_MOBILE_CONNECTED: BinarySensorDeviceClass.CONNECTIVITY, -} - -ALL_SENSORS = list(SENSOR_UNITS) -DEFAULT_SENSORS = [SENSOR_USAGE] - -ALL_BINARY_SENSORS = list(BINARY_SENSOR_CLASSES) -DEFAULT_BINARY_SENSORS = [BINARY_SENSOR_MOBILE_CONNECTED] diff --git a/homeassistant/components/netgear_lte/strings.json b/homeassistant/components/netgear_lte/strings.json index 1fd1028299140b..5719d693d15756 100644 --- a/homeassistant/components/netgear_lte/strings.json +++ b/homeassistant/components/netgear_lte/strings.json @@ -1,4 +1,32 @@ { + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_notify": { + "title": "The Netgear LTE notify service is changing", + "description": "The Netgear LTE notify service was previously set up via YAML configuration.\n\nThis created a service for a specified recipient without having to include the phone number.\n\nPlease adjust any automations or scripts you may have to use the `{name}` service and include target for specifying a recipient." + }, + "import_failure": { + "title": "The Netgear LTE integration failed to import", + "description": "The Netgear LTE notify service was previously set up via YAML configuration.\n\nAn error occurred when trying to communicate with the device while attempting to import the configuration to the UI.\n\nPlease remove the Netgear LTE notify section from your YAML configuration and set it up in the UI instead." + } + }, "services": { "delete_sms": { "name": "Delete SMS", @@ -53,5 +81,58 @@ } } }, - "selector": {} + "entity": { + "binary_sensor": { + "mobile_connected": { + "name": "Mobile connected" + }, + "roaming": { + "name": "Roaming" + }, + "wire_connected": { + "name": "Wire connected" + } + }, + "sensor": { + "band": { + "name": "Current band" + }, + "cell_id": { + "name": "Cell ID" + }, + "connection_text": { + "name": "Connection text" + }, + "connection_type": { + "name": "Connection type" + }, + "radio_quality": { + "name": "Radio quality" + }, + "register_network_display": { + "name": "Register network display" + }, + "rx_level": { + "name": "Rx level" + }, + "service_type": { + "name": "Service type" + }, + "sms": { + "name": "SMS" + }, + "sms_total": { + "name": "SMS total" + }, + "tx_level": { + "name": "Tx level" + }, + "upstream": { + "name": "Upstream" + }, + "usage": { + "name": "Usage" + } + } + } } diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index e1f4dcc284056b..e8c0bc224febc3 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -1,10 +1,10 @@ """NextBus platform.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_STOP, Platform from homeassistant.core import HomeAssistant -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN from .coordinator import NextBusDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/nextbus/config_flow.py b/homeassistant/components/nextbus/config_flow.py index 84417a29c8d09c..a4045ada372914 100644 --- a/homeassistant/components/nextbus/config_flow.py +++ b/homeassistant/components/nextbus/config_flow.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( SelectOptionDict, @@ -15,7 +15,7 @@ SelectSelectorMode, ) -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN from .util import listify _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nextbus/const.py b/homeassistant/components/nextbus/const.py index 9d9d0a5262fdd3..0a2eabf57b38af 100644 --- a/homeassistant/components/nextbus/const.py +++ b/homeassistant/components/nextbus/const.py @@ -3,4 +3,3 @@ CONF_AGENCY = "agency" CONF_ROUTE = "route" -CONF_STOP = "stop" diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 6ef647f98adcec..f62bf07eeefa43 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -13,7 +13,7 @@ SensorEntity, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,7 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN from .coordinator import NextBusDataUpdateCoordinator from .util import listify, maybe_first diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 16c8adb77cee8c..851cb9f3cd35c5 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -10,6 +10,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,37 +30,41 @@ UNIT_OF_LOAD: Final[str] = "load" -@dataclass +@dataclass(frozen=True) 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 @@ class NextcloudSensorEntityDescription(SensorEntityDescription): 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 @@ class NextcloudSensorEntityDescription(SensorEntityDescription): 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 @@ class NextcloudSensorEntityDescription(SensorEntityDescription): 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 @@ class NextcloudSensorEntityDescription(SensorEntityDescription): 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 @@ class NextcloudSensorEntityDescription(SensorEntityDescription): 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 @@ class NextcloudSensorEntityDescription(SensorEntityDescription): 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 @@ class NextcloudSensorEntityDescription(SensorEntityDescription): 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 @@ class NextcloudSensorEntityDescription(SensorEntityDescription): 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/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index e2e37ccab2d7ca..dad29893161d3d 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -24,14 +24,14 @@ PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class NextDnsBinarySensorRequiredKeysMixin(Generic[CoordinatorDataT]): """Mixin for required keys.""" state: Callable[[CoordinatorDataT, str], bool] -@dataclass +@dataclass(frozen=True) class NextDnsBinarySensorEntityDescription( BinarySensorEntityDescription, NextDnsBinarySensorRequiredKeysMixin[CoordinatorDataT], diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index 3985644a478cf3..c502f788a86778 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -9,11 +9,11 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_PROFILE_ID, CONF_PROFILE_NAME, DOMAIN +from .const import CONF_PROFILE_ID, DOMAIN class NextDnsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/nextdns/const.py b/homeassistant/components/nextdns/const.py index 8cac556c87c0d4..031dd1c5814e59 100644 --- a/homeassistant/components/nextdns/const.py +++ b/homeassistant/components/nextdns/const.py @@ -10,7 +10,6 @@ ATTR_STATUS = "status" CONF_PROFILE_ID = "profile_id" -CONF_PROFILE_NAME = "profile_name" UPDATE_INTERVAL_CONNECTION = timedelta(minutes=5) UPDATE_INTERVAL_ANALYTICS = timedelta(minutes=10) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 725ce1b920189a..611021d73e478d 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.1"] + "requirements": ["nextdns==2.1.0"] } diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index ccbbb5e534ee06..c501142697eb91 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -38,7 +38,7 @@ PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class NextDnsSensorRequiredKeysMixin(Generic[CoordinatorDataT]): """Class for NextDNS entity required keys.""" @@ -46,7 +46,7 @@ class NextDnsSensorRequiredKeysMixin(Generic[CoordinatorDataT]): value: Callable[[CoordinatorDataT], StateType] -@dataclass +@dataclass(frozen=True) class NextDnsSensorEntityDescription( SensorEntityDescription, NextDnsSensorRequiredKeysMixin[CoordinatorDataT], diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 0a310bc29e70b6..177b4970a93b6e 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -24,14 +24,14 @@ PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class NextDnsSwitchRequiredKeysMixin(Generic[CoordinatorDataT]): """Class for NextDNS entity required keys.""" state: Callable[[CoordinatorDataT], bool] -@dataclass +@dataclass(frozen=True) class NextDnsSwitchEntityDescription( SwitchEntityDescription, NextDnsSwitchRequiredKeysMixin[CoordinatorDataT] ): diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index fdc9f01d343c4c..cde02327712e09 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/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 6280994bd7da10..38a3a5f825c586 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 @@ -124,7 +125,7 @@ def _get(address: int) -> Coil: @callback def _handle_coordinator_update(self) -> None: - 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: diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index 853da6e5232012..ce75247083b834 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -4,7 +4,7 @@ import asyncio from collections import defaultdict from collections.abc import Callable, Iterable -from datetime import timedelta +from datetime import date, timedelta from typing import Any, Generic, TypeVar from nibe.coil import Coil, CoilData @@ -123,7 +123,7 @@ 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: + 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 @@ -132,7 +132,7 @@ def get_coil_value(self, coil: Coil) -> int | str | float | 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 float(value) # type: ignore[arg-type] return None async def async_write_coil(self, coil: Coil, value: int | float | str) -> None: diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 355ce84525fa81..94a2a76c81404a 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 addfacf4faf103..83ccc124e5184e 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -66,7 +66,7 @@ def _async_read_coil(self, data: CoilData) -> None: 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/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py index c9d1d89c6ca4dd..db688fdb69c3d4 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 @@ -132,7 +134,7 @@ def _get_float(coil: Coil | None) -> float | None: 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/coordinator.py b/homeassistant/components/nina/coordinator.py index eb5c7a7e506bc7..b2c97503442fe8 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -66,7 +66,7 @@ async def _async_update_data(self) -> dict[str, list[NinaWarningData]]: @staticmethod def _remove_duplicate_warnings( - warnings: dict[str, list[Any]] + warnings: dict[str, list[Any]], ) -> dict[str, list[Any]]: """Remove warnings with the same title and expires timestamp in a region.""" all_filtered_warnings: dict[str, list[Any]] = {} diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 2da0a4ae137814..b9464020431e28 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -9,7 +9,7 @@ "loggers": ["nmap"], "requirements": [ "netmap==0.7.0.2", - "getmac==0.8.2", + "getmac==0.9.4", "mac-vendor-lookup==0.1.12" ] } diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index bc2c328d647c89..6c77f98d1b12ff 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -4,26 +4,12 @@ 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 4e6009ce6d71d2..9ddbed7dadc90e 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 00000000000000..2708dd75ffeca8 --- /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 | None = None + + 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 | None = None + + 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 cfa339c98dfa68..28be01862e9e9e 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/notify/legacy.py b/homeassistant/components/notify/legacy.py index 110671864e3a93..7c78bfc44d3342 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -6,17 +6,18 @@ from functools import partial from typing import Any, Protocol, cast +from homeassistant.config import config_per_platform from homeassistant.const import CONF_DESCRIPTION, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform, async_start_setup from homeassistant.util import slugify -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_DATA, @@ -125,7 +126,7 @@ async def async_setup_platform( hass.data[NOTIFY_SERVICES].setdefault(integration_name, []).append( notify_service ) - hass.config.components.add(f"{DOMAIN}.{integration_name}") + hass.config.components.add(f"{integration_name}.{DOMAIN}") async def async_platform_discovered( platform: str, info: DiscoveryInfoType | None @@ -279,8 +280,8 @@ async def async_setup( # Load service descriptions from notify/services.yaml integration = await async_get_integration(hass, DOMAIN) services_yaml = integration.file_path / "services.yaml" - self.services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + self.services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_register_services(self) -> None: diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 036ef6e4f0e740..406acd6aabd734 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -290,17 +290,19 @@ def __init__( """Initialize the entity.""" super().__init__(coordinator) - bridge = self.coordinator.data.bridges[bridge_id] sensor = self.coordinator.data.sensors[sensor_id] + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sensor.hardware_id)}, manufacturer="Silicon Labs", model=str(sensor.hardware_revision), name=str(sensor.name).capitalize(), sw_version=sensor.firmware_version, - via_device=(DOMAIN, bridge.hardware_id), ) + if bridge := self._async_get_bridge(bridge_id): + self._attr_device_info["via_device"] = (DOMAIN, bridge.hardware_id) + self._attr_extra_state_attributes = {} self._attr_unique_id = listener_id self._bridge_id = bridge_id @@ -322,6 +324,14 @@ def listener(self) -> Listener: """Return the listener related to this entity.""" return self.coordinator.data.listeners[self._listener_id] + @callback + def _async_get_bridge(self, bridge_id: int) -> Bridge | None: + """Get a bridge by ID (if it exists).""" + if (bridge := self.coordinator.data.bridges.get(bridge_id)) is None: + LOGGER.debug("Entity references a non-existent bridge ID: %s", bridge_id) + return None + return bridge + @callback def _async_update_bridge_id(self) -> None: """Update the entity's bridge ID if it has changed. @@ -330,13 +340,12 @@ def _async_update_bridge_id(self) -> None: """ sensor = self.coordinator.data.sensors[self._sensor_id] - # If the sensor's bridge ID is the same as what we had before or if it points - # to a bridge that doesn't exist (which can happen due to a Notion API bug), - # return immediately: - if ( - self._bridge_id == sensor.bridge.id - or sensor.bridge.id not in self.coordinator.data.bridges - ): + # If the bridge ID hasn't changed, return: + if self._bridge_id == sensor.bridge.id: + return + + # If the bridge doesn't exist, return: + if (bridge := self._async_get_bridge(sensor.bridge.id)) is None: return self._bridge_id = sensor.bridge.id diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index ff58d566a34776..a1c519f228f864 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -33,14 +33,14 @@ from .model import NotionEntityDescriptionMixin -@dataclass +@dataclass(frozen=True) class NotionBinarySensorDescriptionMixin: """Define an entity description mixin for binary and regular sensors.""" on_state: Literal["alarm", "leak", "low", "not_missing", "open"] -@dataclass +@dataclass(frozen=True) class NotionBinarySensorDescription( BinarySensorEntityDescription, NotionBinarySensorDescriptionMixin, diff --git a/homeassistant/components/notion/model.py b/homeassistant/components/notion/model.py index 0999df3abdbc87..cdfd6e63dad41f 100644 --- a/homeassistant/components/notion/model.py +++ b/homeassistant/components/notion/model.py @@ -4,7 +4,7 @@ from aionotion.sensor.models import ListenerKind -@dataclass +@dataclass(frozen=True) class NotionEntityDescriptionMixin: """Define an description mixin Notion entities.""" diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 4777cc94fbfd24..8c4242aec2a21e 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -19,7 +19,7 @@ from .model import NotionEntityDescriptionMixin -@dataclass +@dataclass(frozen=True) class NotionSensorDescription(SensorEntityDescription, NotionEntityDescriptionMixin): """Describe a Notion sensor.""" diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index ede7a20ccdb211..3f17c0b795b5cc 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -39,7 +39,7 @@ 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 310197d55d8520..4acfecf492b127 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -13,7 +13,7 @@ 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 @@ } ) -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 @@ async def async_step_reauth_confirm(self, user_input=None): 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 @@ async def async_step_reauth_confirm(self, user_input=None): 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 @@ async def async_step_validate(self, user_input=None): 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 dee4a8b8ac523e..21a2dcf9e5b105 100644 --- a/homeassistant/components/nuki/const.py +++ b/homeassistant/components/nuki/const.py @@ -12,3 +12,6 @@ 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 19aeae989f4be0..216b891ac31fa4 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/number/__init__.py b/homeassistant/components/number/__init__.py index 201fa8fedb654b..55b281e02e1cfc 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -7,7 +7,7 @@ from datetime import timedelta import logging from math import ceil, floor -from typing import Any, Self, final +from typing import TYPE_CHECKING, Any, Self, final import voluptuous as vol @@ -42,6 +42,11 @@ ) from .websocket_api import async_setup as async_setup_ws_api +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -120,8 +125,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclasses.dataclass -class NumberEntityDescription(EntityDescription): +class NumberEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes number entities.""" device_class: NumberDeviceClass | None = None @@ -154,7 +158,18 @@ def floor_decimal(value: float, precision: float = 0) -> float: return floor(value * factor) / factor -class NumberEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "native_max_value", + "native_min_value", + "native_step", + "mode", + "native_unit_of_measurement", + "native_value", +} + + +class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Number entity.""" _entity_component_unrecorded_attributes = frozenset( @@ -239,7 +254,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @property + @cached_property def device_class(self) -> NumberDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -248,7 +263,7 @@ def device_class(self) -> NumberDeviceClass | None: return self.entity_description.device_class return None - @property + @cached_property def native_min_value(self) -> float: """Return the minimum value.""" if hasattr(self, "_attr_native_min_value"): @@ -268,7 +283,7 @@ def min_value(self) -> float: self.native_min_value, floor_decimal, self.device_class ) - @property + @cached_property def native_max_value(self) -> float: """Return the maximum value.""" if hasattr(self, "_attr_native_max_value"): @@ -288,9 +303,11 @@ def max_value(self) -> float: self.native_max_value, ceil_decimal, self.device_class ) - @property + @cached_property def native_step(self) -> float | None: """Return the increment/decrement step.""" + if hasattr(self, "_attr_native_step"): + return self._attr_native_step if ( hasattr(self, "entity_description") and self.entity_description.native_step is not None @@ -306,8 +323,6 @@ def step(self) -> float: def _calculate_step(self, min_value: float, max_value: float) -> float: """Return the increment/decrement step.""" - if hasattr(self, "_attr_native_step"): - return self._attr_native_step if (native_step := self.native_step) is not None: return native_step step = DEFAULT_STEP @@ -317,7 +332,7 @@ def _calculate_step(self, min_value: float, max_value: float) -> float: step /= 10.0 return step - @property + @cached_property def mode(self) -> NumberMode: """Return the mode of the entity.""" if hasattr(self, "_attr_mode"): @@ -335,7 +350,7 @@ def state(self) -> float | None: """Return the entity state.""" return self.value - @property + @cached_property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the entity, if any.""" if hasattr(self, "_attr_native_unit_of_measurement"): @@ -363,7 +378,7 @@ def unit_of_measurement(self) -> str | None: return native_unit_of_measurement - @property + @cached_property def native_value(self) -> float | None: """Return the value reported by the number.""" return self._attr_native_value diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 9248d3f9e575a0..55d22c86648935 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import StrEnum +from functools import partial from typing import Final import voluptuous as vol @@ -35,6 +36,11 @@ UnitOfVolume, UnitOfVolumetricFlux, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.util.unit_conversion import BaseUnitConverter, TemperatureConverter ATTR_VALUE = "value" @@ -50,16 +56,29 @@ SERVICE_SET_VALUE = "set_value" + +class NumberMode(StrEnum): + """Modes for number entities.""" + + AUTO = "auto" + BOX = "box" + SLIDER = "slider" + + # MODE_* are deprecated as of 2021.12, use the NumberMode enum instead. -MODE_AUTO: Final = "auto" -MODE_BOX: Final = "box" -MODE_SLIDER: Final = "slider" +_DEPRECATED_MODE_AUTO: Final = DeprecatedConstantEnum(NumberMode.AUTO, "2025.1") +_DEPRECATED_MODE_BOX: Final = DeprecatedConstantEnum(NumberMode.BOX, "2025.1") +_DEPRECATED_MODE_SLIDER: Final = DeprecatedConstantEnum(NumberMode.SLIDER, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) class NumberDeviceClass(StrEnum): """Device class for numbers.""" - # NumberDeviceClass should be aligned with NumberDeviceClass + # NumberDeviceClass should be aligned with SensorDeviceClass APPARENT_POWER = "apparent_power" """Apparent power. @@ -385,14 +404,6 @@ class NumberDeviceClass(StrEnum): """ -class NumberMode(StrEnum): - """Modes for number entities.""" - - AUTO = "auto" - BOX = "box" - SLIDER = "slider" - - DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass)) DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), diff --git a/homeassistant/components/number/significant_change.py b/homeassistant/components/number/significant_change.py new file mode 100644 index 00000000000000..11bca6457f1efc --- /dev/null +++ b/homeassistant/components/number/significant_change.py @@ -0,0 +1,92 @@ +"""Helper to test significant Number state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_percentage_change, + check_valid_float, +) + +from .const import NumberDeviceClass + + +def _absolute_and_relative_change( + old_state: int | float | None, + new_state: int | float | None, + absolute_change: int | float, + percentage_change: int | float, +) -> bool: + return check_absolute_change( + old_state, new_state, absolute_change + ) and check_percentage_change(old_state, new_state, percentage_change) + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if (device_class := new_attrs.get(ATTR_DEVICE_CLASS)) is None: + return None + + absolute_change: float | None = None + percentage_change: float | None = None + + # special for temperature + if device_class == NumberDeviceClass.TEMPERATURE: + if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + # special for percentage + elif device_class in ( + NumberDeviceClass.BATTERY, + NumberDeviceClass.HUMIDITY, + NumberDeviceClass.MOISTURE, + ): + absolute_change = 1.0 + + # special for power factor + elif device_class == NumberDeviceClass.POWER_FACTOR: + if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE: + absolute_change = 1.0 + else: + absolute_change = 0.1 + percentage_change = 2.0 + + # default for all other classified + else: + absolute_change = 1.0 + percentage_change = 2.0 + + if not check_valid_float(new_state): + # New state is invalid, don't report it + return False + + if not check_valid_float(old_state): + # Old state was invalid, we should report again + return True + + if absolute_change is not None and percentage_change is not None: + return _absolute_and_relative_change( + float(old_state), float(new_state), absolute_change, percentage_change + ) + if absolute_change is not None: + return check_absolute_change( + float(old_state), float(new_state), absolute_change + ) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 2827911a3aa318..7347744d56f0f9 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 05194d85a26dfd..4006a145db4611 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/nws/sensor.py b/homeassistant/components/nws/sensor.py index ecf9d39ae559d0..35fb6c0ec1fcec 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -39,7 +39,7 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class NWSSensorEntityDescription(SensorEntityDescription): """Class describing NWSSensor entities.""" diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 823bc2e1b8de11..f21b4b3706d4d1 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/__init__.py b/homeassistant/components/octoprint/__init__.py index 5fd2182ca00e07..50ba6c964f3a4f 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -26,6 +26,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify as util_slugify +from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import DOMAIN from .coordinator import OctoprintDataUpdateCoordinator @@ -159,7 +160,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: connector = aiohttp.TCPConnector( force_close=True, - ssl=False if not entry.data[CONF_VERIFY_SSL] else None, + ssl=get_default_no_verify_context() + if not entry.data[CONF_VERIFY_SSL] + else get_default_context(), ) session = aiohttp.ClientSession(connector=connector) diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 99052993a61ec0..a69557065084ae 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -7,8 +7,8 @@ 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/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 09ac53ecf5bfbb..696898400bf6fb 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -24,6 +24,7 @@ ) from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import DOMAIN @@ -264,7 +265,9 @@ def _get_octoprint_client(self, user_input: dict) -> OctoprintClient: connector = aiohttp.TCPConnector( force_close=True, - ssl=False if not verify_ssl else None, + ssl=get_default_no_verify_context() + if not verify_ssl + else get_default_context(), ) session = aiohttp.ClientSession(connector=connector) self._sessions.append(session) diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index c6dbfe6f9c42f9..63d9753ee1d8c2 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 7b049e66ae226d..26199b1bd754d7 100644 --- a/homeassistant/components/ondilo_ico/strings.json +++ b/homeassistant/components/ondilo_ico/strings.json @@ -10,8 +10,8 @@ "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]" + "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/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index b405140bc322ea..2840cde704bc1e 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -25,7 +25,7 @@ from .onewirehub import OneWireHub -@dataclass +@dataclass(frozen=True) class OneWireBinarySensorEntityDescription( OneWireEntityDescription, BinarySensorEntityDescription ): diff --git a/homeassistant/components/onewire/onewire_entities.py b/homeassistant/components/onewire/onewire_entities.py index a6eddece5c6f8c..cad55234181f09 100644 --- a/homeassistant/components/onewire/onewire_entities.py +++ b/homeassistant/components/onewire/onewire_entities.py @@ -14,7 +14,7 @@ from .const import READ_MODE_BOOL, READ_MODE_INT -@dataclass +@dataclass(frozen=True) class OneWireEntityDescription(EntityDescription): """Class describing OneWire entities.""" diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 34ed66bd511ce8..cc8b14b5d6ed6a 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping -import copy -from dataclasses import dataclass +import dataclasses import logging import os from types import MappingProxyType @@ -43,7 +42,7 @@ from .onewirehub import OneWireHub -@dataclass +@dataclasses.dataclass(frozen=True) class OneWireSensorEntityDescription(OneWireEntityDescription, SensorEntityDescription): """Class describing OneWire sensor entities.""" @@ -393,11 +392,12 @@ def get_entities( ).decode() ) if is_leaf: - description = copy.deepcopy(description) - description.device_class = SensorDeviceClass.HUMIDITY - description.native_unit_of_measurement = PERCENTAGE - description.translation_key = f"wetness_{s_id}" - _LOGGER.info(description.translation_key) + description = dataclasses.replace( + description, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + translation_key=f"wetness_{s_id}", + ) override_key = None if description.override_key: override_key = description.override_key(device_id, options) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9e4120b68b2137..753f244cfe9d11 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/onewire/switch.py b/homeassistant/components/onewire/switch.py index 986be11d1695d4..db9e8f5b0f8d35 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -22,7 +22,7 @@ from .onewirehub import OneWireHub -@dataclass +@dataclass(frozen=True) class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescription): """Class describing OneWire switch entities.""" diff --git a/homeassistant/components/onvif/base.py b/homeassistant/components/onvif/base.py index 8771ae7a70197f..5f8a7d978d1b43 100644 --- a/homeassistant/components/onvif/base.py +++ b/homeassistant/components/onvif/base.py @@ -32,8 +32,7 @@ def mac_or_serial(self) -> str: 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 cabab347264bf9..5a36b89688aebf 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/onvif/switch.py b/homeassistant/components/onvif/switch.py index 4f7de67386b663..673f77f558c044 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -16,7 +16,7 @@ from .models import Profile -@dataclass +@dataclass(frozen=True) class ONVIFSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class ONVIFSwitchEntityDescriptionMixin: supported_fn: Callable[[ONVIFDevice], bool] -@dataclass +@dataclass(frozen=True) class ONVIFSwitchEntityDescription( SwitchEntityDescription, ONVIFSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 9f4c30d91baf4f..b0762979ca217c 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -1,12 +1,10 @@ """The OpenAI Conversation integration.""" from __future__ import annotations -from functools import partial import logging from typing import Literal import openai -from openai import error import voluptuous as vol from homeassistant.components import conversation @@ -23,7 +21,13 @@ HomeAssistantError, TemplateError, ) -from homeassistant.helpers import config_validation as cv, intent, selector, template +from homeassistant.helpers import ( + config_validation as cv, + intent, + issue_registry as ir, + selector, + template, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import ulid @@ -52,17 +56,38 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def render_image(call: ServiceCall) -> ServiceResponse: """Render an image with dall-e.""" + client = hass.data[DOMAIN][call.data["config_entry"]] + + if call.data["size"] in ("256", "512", "1024"): + ir.async_create_issue( + hass, + DOMAIN, + "image_size_deprecated_format", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + is_persistent=True, + learn_more_url="https://www.home-assistant.io/integrations/openai_conversation/", + severity=ir.IssueSeverity.WARNING, + translation_key="image_size_deprecated_format", + ) + size = "1024x1024" + else: + size = call.data["size"] + try: - response = await openai.Image.acreate( - api_key=hass.data[DOMAIN][call.data["config_entry"]], + response = await client.images.generate( + model="dall-e-3", prompt=call.data["prompt"], + size=size, + quality=call.data["quality"], + style=call.data["style"], + response_format="url", n=1, - size=f'{call.data["size"]}x{call.data["size"]}', ) - except error.OpenAIError as err: + except openai.OpenAIError as err: raise HomeAssistantError(f"Error generating image: {err}") from err - return response["data"][0] + return response.data[0].model_dump(exclude={"b64_json"}) hass.services.async_register( DOMAIN, @@ -76,7 +101,11 @@ async def render_image(call: ServiceCall) -> ServiceResponse: } ), vol.Required("prompt"): cv.string, - vol.Optional("size", default="512"): vol.In(("256", "512", "1024")), + vol.Optional("size", default="1024x1024"): vol.In( + ("1024x1024", "1024x1792", "1792x1024", "256", "512", "1024") + ), + vol.Optional("quality", default="standard"): vol.In(("standard", "hd")), + vol.Optional("style", default="vivid"): vol.In(("vivid", "natural")), } ), supports_response=SupportsResponse.ONLY, @@ -86,21 +115,16 @@ async def render_image(call: ServiceCall) -> ServiceResponse: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up OpenAI Conversation from a config entry.""" + client = openai.AsyncOpenAI(api_key=entry.data[CONF_API_KEY]) try: - await hass.async_add_executor_job( - partial( - openai.Engine.list, - api_key=entry.data[CONF_API_KEY], - request_timeout=10, - ) - ) - except error.AuthenticationError as err: + await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) + except openai.AuthenticationError as err: _LOGGER.error("Invalid API key: %s", err) return False - except error.OpenAIError as err: + except openai.OpenAIError as err: raise ConfigEntryNotReady(err) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data[CONF_API_KEY] + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client conversation.async_set_agent(hass, entry, OpenAIAgent(hass, entry)) return True @@ -141,7 +165,7 @@ async def async_process( 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: @@ -160,9 +184,10 @@ async def async_process( _LOGGER.debug("Prompt for %s: %s", model, messages) + client = self.hass.data[DOMAIN][self.entry.entry_id] + try: - result = await openai.ChatCompletion.acreate( - api_key=self.entry.data[CONF_API_KEY], + result = await client.chat.completions.create( model=model, messages=messages, max_tokens=max_tokens, @@ -170,7 +195,7 @@ async def async_process( temperature=temperature, user=conversation_id, ) - except error.OpenAIError as err: + except openai.OpenAIError as err: intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, @@ -181,7 +206,7 @@ async def async_process( ) _LOGGER.debug("Response %s", result) - response = result["choices"][0]["message"] + response = result.choices[0].message.model_dump(include={"role", "content"}) messages.append(response) self.history[conversation_id] = messages diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index b391f531eb1c24..ef1e498d061c08 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -1,14 +1,12 @@ """Config flow for OpenAI Conversation integration.""" from __future__ import annotations -from functools import partial import logging import types from types import MappingProxyType from typing import Any import openai -from openai import error import voluptuous as vol from homeassistant import config_entries @@ -59,8 +57,8 @@ 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)) + client = openai.AsyncOpenAI(api_key=data[CONF_API_KEY]) + await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -81,9 +79,9 @@ async def async_step_user( try: await validate_input(self.hass, user_input) - except error.APIConnectionError: + except openai.APIConnectionError: errors["base"] = "cannot_connect" - except error.AuthenticationError: + except openai.AuthenticationError: errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 88d347355e9ed1..5138be96b55e01 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==0.27.2"] + "requirements": ["openai==1.3.8"] } diff --git a/homeassistant/components/openai_conversation/services.yaml b/homeassistant/components/openai_conversation/services.yaml index 81818fb3e71f13..3db71cae38383d 100644 --- a/homeassistant/components/openai_conversation/services.yaml +++ b/homeassistant/components/openai_conversation/services.yaml @@ -11,12 +11,30 @@ generate_image: text: multiline: true size: - required: true - example: "512" - default: "512" + required: false + example: "1024x1024" + default: "1024x1024" + selector: + select: + options: + - "1024x1024" + - "1024x1792" + - "1792x1024" + quality: + required: false + example: "standard" + default: "standard" + selector: + select: + options: + - "standard" + - "hd" + style: + required: false + example: "vivid" + default: "vivid" selector: select: options: - - "256" - - "512" - - "1024" + - "vivid" + - "natural" diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 542fe06dd5687b..1a7d5a03c6532d 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -43,8 +43,22 @@ "size": { "name": "Size", "description": "The size of the image to generate" + }, + "quality": { + "name": "Quality", + "description": "The quality of the image that will be generated" + }, + "style": { + "name": "Style", + "description": "The style of the generated image" } } } + }, + "issues": { + "image_size_deprecated_format": { + "title": "Deprecated size format for image generation service", + "description": "OpenAI is now using Dall-E 3 to generate images when calling `openai_conversation.generate_image`, which supports different sizes. Valid values are now \"1024x1024\", \"1024x1792\", \"1792x1024\". The old values of \"256\", \"512\", \"1024\" are currently interpreted as \"1024x1024\".\nPlease update your scripts or automations with the new parameters." + } } } diff --git a/homeassistant/components/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py index a61264dbf4115a..b78227ed1e5122 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -66,7 +66,11 @@ async def async_step_user( self._reauth_entry.data if self._reauth_entry else {} ) return self.async_show_form( - step_id="user", data_schema=get_data_schema(currencies, existing_data) + step_id="user", + data_schema=get_data_schema(currencies, existing_data), + description_placeholders={ + "signup": "https://openexchangerates.org/signup" + }, ) errors = {} diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 70f2f670de86c8..66baf54c16a314 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -64,4 +64,4 @@ def __init__( @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/__init__.py b/homeassistant/components/opengarage/__init__.py index c269ee53cf3f35..b825cace83adc1 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -18,13 +18,11 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up OpenGarage from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - open_garage_connection = opengarage.OpenGarage( f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", entry.data[CONF_DEVICE_KEY], @@ -36,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: open_garage_connection=open_garage_connection, ) await open_garage_data_coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = open_garage_data_coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = open_garage_data_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/opengarage/button.py b/homeassistant/components/opengarage/button.py new file mode 100644 index 00000000000000..9f6769190984de --- /dev/null +++ b/homeassistant/components/opengarage/button.py @@ -0,0 +1,79 @@ +"""OpenGarage button.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast + +from opengarage import OpenGarage + +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.entity_platform import AddEntitiesCallback + +from . import OpenGarageDataUpdateCoordinator +from .const import DOMAIN +from .entity import OpenGarageEntity + + +@dataclass(frozen=True, kw_only=True) +class OpenGarageButtonEntityDescription(ButtonEntityDescription): + """OpenGarage Browser button description.""" + + press_action: Callable[[OpenGarage], Any] + + +BUTTONS: tuple[OpenGarageButtonEntityDescription, ...] = ( + OpenGarageButtonEntityDescription( + key="restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=lambda opengarage: opengarage.reboot(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the OpenGarage button entities.""" + coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities( + OpenGarageButtonEntity( + coordinator, cast(str, config_entry.unique_id), description + ) + for description in BUTTONS + ) + + +class OpenGarageButtonEntity(OpenGarageEntity, ButtonEntity): + """Representation of an OpenGarage button.""" + + entity_description: OpenGarageButtonEntityDescription + + def __init__( + self, + coordinator: OpenGarageDataUpdateCoordinator, + device_id: str, + description: OpenGarageButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(coordinator, device_id, description) + + async def async_press(self) -> None: + """Press the button.""" + await self.entity_description.press_action( + self.coordinator.open_garage_connection + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json index ba4521d4dcf26b..f19b458cd0fbbd 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/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 51d7774a2fb865..a4a16c6713ca12 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -79,7 +79,7 @@ def catch_request_errors() -> ( """Catch asyncio.TimeoutError, aiohttp.ClientError, UpnpError errors.""" def call_wrapper( - func: _FuncType[_OpenhomeDeviceT, _P, _R] + func: _FuncType[_OpenhomeDeviceT, _P, _R], ) -> _ReturnFuncType[_OpenhomeDeviceT, _P, _R]: """Call wrapper for decorator.""" diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index d33dfec6adfea0..106103cf75293e 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.2.1"] + "requirements": ["python-opensky==1.0.0"] } diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index a6c75c171136ae..7dc2d206912e6c 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -409,25 +409,25 @@ ], gw_vars.DATA_TOTAL_BURNER_STARTS: [ None, - None, + "starts", "Total Burner Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_PUMP_STARTS: [ None, - None, + "starts", "Central Heating Pump Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_PUMP_STARTS: [ None, - None, + "starts", "Hot Water Pump Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_BURNER_STARTS: [ None, - None, + "starts", "Hot Water Burner Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 8434b6d559159f..431fa41a288543 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -71,14 +71,14 @@ def get_uv_label(uv_index: int) -> str: return label.value -@dataclass +@dataclass(frozen=True) class OpenUvSensorEntityDescriptionMixin: """Define a mixin for OpenUV sensor descriptions.""" value_fn: Callable[[dict[str, Any]], int | str] -@dataclass +@dataclass(frozen=True) class OpenUvSensorEntityDescription( SensorEntityDescription, OpenUvSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index d462e34cd846ad..cfe28e2eacc60b 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, @@ -18,7 +19,6 @@ from homeassistant.core import HomeAssistant from .const import ( - CONF_LANGUAGE, CONFIG_FLOW_VERSION, DOMAIN, ENTRY_NAME, diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index c418231946f418..799be35fb42061 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -8,6 +8,7 @@ from homeassistant import config_entries from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, @@ -17,7 +18,6 @@ import homeassistant.helpers.config_validation as cv from .const import ( - CONF_LANGUAGE, CONFIG_FLOW_VERSION, DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 1420b1170ca422..d7deab21743fe4 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -24,7 +24,6 @@ DEFAULT_LANGUAGE = "en" ATTRIBUTION = "Data provided by OpenWeatherMap" MANUFACTURER = "OpenWeather" -CONF_LANGUAGE = "language" CONFIG_FLOW_VERSION = 2 ENTRY_NAME = "name" ENTRY_WEATHER_COORDINATOR = "weather_coordinator" diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index d456fc536e58ff..ab1fbbe36e3b5d 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -3,6 +3,7 @@ from collections.abc import Mapping import logging +import socket from typing import Any from opower import ( @@ -38,7 +39,7 @@ async def _validate_login( ) -> dict[str, str]: """Validate login data and return any errors.""" api = Opower( - async_create_clientsession(hass), + async_create_clientsession(hass, family=socket.AF_INET), login_data[CONF_UTILITY], login_data[CONF_USERNAME], login_data[CONF_PASSWORD], diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 239f23e7523653..73c60068cd4571 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -1,6 +1,7 @@ """Coordinator to handle Opower connections.""" from datetime import datetime, timedelta import logging +import socket from types import MappingProxyType from typing import Any, cast @@ -51,7 +52,7 @@ def __init__( update_interval=timedelta(hours=12), ) self.api = Opower( - aiohttp_client.async_get_clientsession(hass), + aiohttp_client.async_get_clientsession(hass, family=socket.AF_INET), entry_data[CONF_UTILITY], entry_data[CONF_USERNAME], entry_data[CONF_PASSWORD], @@ -93,7 +94,9 @@ async def _insert_statistics(self) -> None: ( self.api.utility.subdomain(), account.meter_type.name.lower(), - account.utility_account_id, + # Some utilities like AEP have "-" in their account id. + # Replace it with "_" to avoid "Invalid statistic_id" + account.utility_account_id.replace("-", "_"), ) ) cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 1022ab07e2ce00..89b6291271089a 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.39"] + "requirements": ["opower==0.1.0"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 175bef01449959..9940132dac2b07 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -24,14 +24,14 @@ from .coordinator import OpowerCoordinator -@dataclass +@dataclass(frozen=True) class OpowerEntityDescriptionMixin: """Mixin values for required keys.""" value_fn: Callable[[Forecast], str | float] -@dataclass +@dataclass(frozen=True) class OpowerEntityDescription(SensorEntityDescription, OpowerEntityDescriptionMixin): """Class describing Opower sensors entities.""" diff --git a/homeassistant/components/orvibo/manifest.json b/homeassistant/components/orvibo/manifest.json index 72cdc4118dffa1..05ce5edd8bd313 100644 --- a/homeassistant/components/orvibo/manifest.json +++ b/homeassistant/components/orvibo/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/orvibo", "iot_class": "local_push", "loggers": ["orvibo"], - "requirements": ["orvibo==1.1.1"] + "requirements": ["orvibo==1.1.2"] } diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py new file mode 100644 index 00000000000000..f0b89eaea9031e --- /dev/null +++ b/homeassistant/components/osoenergy/__init__.py @@ -0,0 +1,81 @@ +"""Support for the OSO Energy devices and services.""" +from typing import Any, Generic, TypeVar + +from aiohttp.web_exceptions import HTTPException +from apyosoenergyapi import OSOEnergy +from apyosoenergyapi.helper.const import ( + OSOEnergyBinarySensorData, + OSOEnergySensorData, + OSOEnergyWaterHeaterData, +) +from apyosoenergyapi.helper.osoenergy_exceptions import OSOEnergyReauthRequired + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_T = TypeVar( + "_T", OSOEnergyBinarySensorData, OSOEnergySensorData, OSOEnergyWaterHeaterData +) + +PLATFORMS = [ + Platform.WATER_HEATER, +] +PLATFORM_LOOKUP = { + Platform.WATER_HEATER: "water_heater", +} + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OSO Energy from a config entry.""" + subscription_key = entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + osoenergy = OSOEnergy(subscription_key, websession) + + osoenergy_config = dict(entry.data) + + hass.data.setdefault(DOMAIN, {}) + + try: + devices: Any = await osoenergy.session.start_session(osoenergy_config) + except HTTPException as error: + raise ConfigEntryNotReady() from error + except OSOEnergyReauthRequired as err: + raise ConfigEntryAuthFailed from err + + hass.data[DOMAIN][entry.entry_id] = osoenergy + + platforms = set() + for ha_type, oso_type in PLATFORM_LOOKUP.items(): + device_list = devices.get(oso_type, []) + if device_list: + platforms.add(ha_type) + if platforms: + 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) + + return unload_ok + + +class OSOEnergyEntity(Entity, Generic[_T]): + """Initiate OSO Energy Base Class.""" + + _attr_has_entity_name = True + + def __init__(self, osoenergy: OSOEnergy, osoenergy_device: _T) -> None: + """Initialize the instance.""" + self.osoenergy = osoenergy + self.device = osoenergy_device + self._attr_unique_id = osoenergy_device.device_id diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py new file mode 100644 index 00000000000000..a7632b19bcbd12 --- /dev/null +++ b/homeassistant/components/osoenergy/config_flow.py @@ -0,0 +1,75 @@ +"""Config Flow for OSO Energy.""" +from collections.abc import Mapping +import logging +from typing import Any + +from apyosoenergyapi import OSOEnergy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +_SCHEMA_STEP_USER = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +class OSOEnergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a OSO Energy config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self) -> None: + """Initialize.""" + self.entry: ConfigEntry | None = None + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + # Verify Subscription key + if user_email := await self.get_user_email(user_input[CONF_API_KEY]): + await self.async_set_unique_id(user_email) + + if ( + self.context["source"] == config_entries.SOURCE_REAUTH + and self.entry + ): + self.hass.config_entries.async_update_entry( + self.entry, title=user_email, data=user_input + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_email, data=user_input) + + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", + data_schema=_SCHEMA_STEP_USER, + errors=errors, + ) + + async def get_user_email(self, subscription_key: str) -> str | None: + """Return the user email for the provided subscription key.""" + try: + websession = aiohttp_client.async_get_clientsession(self.hass) + client = OSOEnergy(subscription_key, websession) + return await client.get_user_email() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error occurred") + return None + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Re Authenticate a user.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + data = {CONF_API_KEY: user_input[CONF_API_KEY]} + return await self.async_step_user(data) diff --git a/homeassistant/components/osoenergy/const.py b/homeassistant/components/osoenergy/const.py new file mode 100644 index 00000000000000..c3925f5259bb45 --- /dev/null +++ b/homeassistant/components/osoenergy/const.py @@ -0,0 +1,3 @@ +"""Constants for OSO Energy.""" + +DOMAIN = "osoenergy" diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json new file mode 100644 index 00000000000000..d681310824280a --- /dev/null +++ b/homeassistant/components/osoenergy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "osoenergy", + "name": "OSO Energy", + "codeowners": ["@osohotwateriot"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/osoenergy", + "iot_class": "cloud_polling", + "requirements": ["pyosoenergyapi==1.1.3"] +} diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json new file mode 100644 index 00000000000000..a45482bf030b3a --- /dev/null +++ b/homeassistant/components/osoenergy/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "OSO Energy Auth", + "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth": { + "title": "OSO Energy Auth", + "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py new file mode 100644 index 00000000000000..4b2ad7c48d657f --- /dev/null +++ b/homeassistant/components/osoenergy/water_heater.py @@ -0,0 +1,142 @@ +"""Support for OSO Energy water heaters.""" +from typing import Any + +from apyosoenergyapi.helper.const import OSOEnergyWaterHeaterData + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +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 . import OSOEnergyEntity +from .const import DOMAIN + +CURRENT_OPERATION_MAP: dict[str, Any] = { + "default": { + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, + "oso": { + "auto": STATE_ECO, + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, +} +HEATER_MIN_TEMP = 10 +HEATER_MAX_TEMP = 80 +MANUFACTURER = "OSO Energy" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up OSO Energy heater based on a config entry.""" + osoenergy = hass.data[DOMAIN][entry.entry_id] + devices = osoenergy.session.device_list.get("water_heater") + entities = [] + if devices: + for dev in devices: + entities.append(OSOEnergyWaterHeater(osoenergy, dev)) + async_add_entities(entities, True) + + +class OSOEnergyWaterHeater( + OSOEnergyEntity[OSOEnergyWaterHeaterData], WaterHeaterEntity +): + """OSO Energy Water Heater Device.""" + + _attr_name = None + _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.device_id)}, + manufacturer=MANUFACTURER, + model=self.device.device_type, + name=self.device.device_name, + ) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.device.available + + @property + def current_operation(self) -> str: + """Return current operation.""" + status = self.device.current_operation + if status == "off": + return STATE_OFF + + optimization_mode = self.device.optimization_mode.lower() + heater_mode = self.device.heater_mode.lower() + if optimization_mode in CURRENT_OPERATION_MAP: + return CURRENT_OPERATION_MAP[optimization_mode].get( + heater_mode, STATE_ELECTRIC + ) + + return CURRENT_OPERATION_MAP["default"].get(heater_mode, STATE_ELECTRIC) + + @property + def current_temperature(self) -> float: + """Return the current temperature of the heater.""" + return self.device.current_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature + + @property + def target_temperature_high(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_high + + @property + def target_temperature_low(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_low + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.device.min_temperature + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device.max_temperature + + async def async_turn_on(self, **kwargs) -> None: + """Turn on hotwater.""" + await self.osoenergy.hotwater.turn_on(self.device, True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off hotwater.""" + await self.osoenergy.hotwater.turn_off(self.device, True) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + target_temperature = int(kwargs.get("temperature", self.target_temperature)) + profile = [target_temperature] * 24 + + await self.osoenergy.hotwater.set_profile(self.device, profile) + + async def async_update(self) -> None: + """Update all Node data from Hive.""" + await self.osoenergy.session.update_data() + self.device = await self.osoenergy.hotwater.get_water_heater(self.device) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 067282108f101b..85e97209a449a0 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -49,7 +49,7 @@ def _handle_otbr_error( - func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]] + func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]], ) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]: """Handle OTBR errors.""" diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py new file mode 100644 index 00000000000000..ebb928e72d098d --- /dev/null +++ b/homeassistant/components/ourgroceries/__init__.py @@ -0,0 +1,48 @@ +"""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]) + try: + await og.login() + except (AsyncIOTimeoutError, ClientError) as error: + raise ConfigEntryNotReady from error + except InvalidLoginException: + return False + + coordinator = OurGroceriesDataUpdateCoordinator(hass, og) + 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 00000000000000..a982325fceb6e5 --- /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 00000000000000..ba0ff789522cf9 --- /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 00000000000000..c583fb4d5b104d --- /dev/null +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -0,0 +1,48 @@ +"""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) -> None: + """Initialize global OurGroceries data updater.""" + self.og = og + self.lists: list[dict] = [] + self._cache: dict[str, dict] = {} + interval = timedelta(seconds=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + ) + + async def _update_list(self, list_id: str, version_id: str) -> None: + old_version = self._cache.get(list_id, {}).get("list", {}).get("versionId", "") + if old_version == version_id: + return + self._cache[list_id] = await self.og.get_list_items(list_id=list_id) + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch data from OurGroceries.""" + self.lists = (await self.og.get_my_lists())["shoppingLists"] + await asyncio.gather( + *[self._update_list(sl["id"], sl["versionId"]) for sl in self.lists] + ) + return self._cache diff --git a/homeassistant/components/ourgroceries/manifest.json b/homeassistant/components/ourgroceries/manifest.json new file mode 100644 index 00000000000000..ec5a5039b3971b --- /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/komfovent/strings.json b/homeassistant/components/ourgroceries/strings.json similarity index 66% rename from homeassistant/components/komfovent/strings.json rename to homeassistant/components/ourgroceries/strings.json index 074754c1fe0016..78a46954183ac0 100644 --- a/homeassistant/components/komfovent/strings.json +++ b/homeassistant/components/ourgroceries/strings.json @@ -3,7 +3,6 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } @@ -12,11 +11,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_input": "Failed to parse provided hostname", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } } diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py new file mode 100644 index 00000000000000..5b8d19e5aa1801 --- /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.get("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 36713d972b16fc..03a81f673084bb 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -1,31 +1,38 @@ """The Overkiz (by Somfy) integration.""" from __future__ import annotations -import asyncio from collections import defaultdict from dataclasses import dataclass -from typing import cast 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, Setup +from pyoverkiz.models import Device, OverkizServer, Scenario +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, @@ -48,27 +55,39 @@ 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) + + # 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], + ) - # 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 - ) + # 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) try: await client.login() - - setup, scenarios = await asyncio.gather( - *[ - client.get_setup(), - client.get_scenarios(), - ] - ) + setup = await client.get_setup() + + # Local API does expose scenarios, but they are not functional. + # Tracked in https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/21 + if api_type == APIType.CLOUD: + scenarios = await client.get_scenarios() + else: + scenarios = [] except (BadCredentialsException, NotSuchTokenException) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: @@ -78,9 +97,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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, @@ -211,3 +227,31 @@ def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: 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/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index b08ede7df10d3f..e2555308e3435a 100644 --- a/homeassistant/components/overkiz/alarm_control_panel.py +++ b/homeassistant/components/overkiz/alarm_control_panel.py @@ -35,7 +35,7 @@ from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizAlarmDescriptionMixin: """Define an entity description mixin for switch entities.""" @@ -43,7 +43,7 @@ class OverkizAlarmDescriptionMixin: fn_state: Callable[[Callable[[str], OverkizStateType]], str] -@dataclass +@dataclass(frozen=True) class OverkizAlarmDescription( AlarmControlPanelEntityDescription, OverkizAlarmDescriptionMixin ): @@ -95,7 +95,7 @@ def _state_tsk_alarm_controller(select_state: Callable[[str], OverkizStateType]) def _state_stateful_alarm_controller( - select_state: Callable[[str], OverkizStateType] + select_state: Callable[[str], OverkizStateType], ) -> str: """Return the state of the device.""" if state := cast(str, select_state(OverkizState.CORE_ACTIVE_ZONES)): @@ -118,7 +118,7 @@ def _state_stateful_alarm_controller( def _state_myfox_alarm_controller( - select_state: Callable[[str], OverkizStateType] + select_state: Callable[[str], OverkizStateType], ) -> str: """Return the state of the device.""" if ( @@ -141,7 +141,7 @@ def _state_myfox_alarm_controller( def _state_alarm_panel_controller( - select_state: Callable[[str], OverkizStateType] + select_state: Callable[[str], OverkizStateType], ) -> str: """Return the state of the device.""" return MAP_ARM_TYPE[ diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 0d00179ee81e72..975ef4ff834f2b 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -22,14 +22,14 @@ from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizBinarySensorDescriptionMixin: """Define an entity description mixin for binary sensor entities.""" value_fn: Callable[[OverkizStateType], bool] -@dataclass +@dataclass(frozen=True) class OverkizBinarySensorDescription( BinarySensorEntityDescription, OverkizBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py index 8388e2c3b2de01..f8f33db7eed231 100644 --- a/homeassistant/components/overkiz/button.py +++ b/homeassistant/components/overkiz/button.py @@ -17,7 +17,7 @@ from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizButtonDescription(ButtonEntityDescription): """Class to describe an Overkiz button.""" diff --git a/homeassistant/components/overkiz/climate.py b/homeassistant/components/overkiz/climate.py index a94c731ec8f9f1..b6d31a8e6857ef 100644 --- a/homeassistant/components/overkiz/climate.py +++ b/homeassistant/components/overkiz/climate.py @@ -7,7 +7,10 @@ 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 b6345dd9b9504e..c74ff2829cccc5 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,7 @@ 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 @@ -26,3 +28,10 @@ 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 00000000000000..7a9e50d7130be6 --- /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/config_flow.py b/homeassistant/components/overkiz/config_flow.py index eac749f1bc085a..4f3f50bf0e8574 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 - - def __init__(self) -> None: - """Initialize Overkiz Config Flow.""" - super().__init__() + _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" - 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 @@ async def async_step_user( 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 @@ async def async_step_user( # 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 @@ async def async_step_user( ) 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 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes """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 @@ async def async_step_zeroconf( """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 @@ async def _process_discovery(self, gateway_id: str) -> FlowResult: 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 91346b63ce0ced..0f30f64444b74f 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -5,7 +5,13 @@ 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 @@ 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 @@ 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) diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index e5079b3d3b80d0..4630af8bbf8c50 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -6,7 +6,7 @@ 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 ( @@ -79,7 +79,7 @@ async def _async_update_data(self) -> 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 b418bba9e41ba6..f4a8a6a0d458a5 100644 --- a/homeassistant/components/overkiz/cover_entities/generic_cover.py +++ b/homeassistant/components/overkiz/cover_entities/generic_cover.py @@ -27,12 +27,18 @@ 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] diff --git a/homeassistant/components/overkiz/diagnostics.py b/homeassistant/components/overkiz/diagnostics.py index 77ca0227579223..cb8cf6eb22f455 100644 --- a/homeassistant/components/overkiz/diagnostics.py +++ b/homeassistant/components/overkiz/diagnostics.py @@ -3,6 +3,7 @@ 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.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/manifest.json b/homeassistant/components/overkiz/manifest.json index f57e351a28273e..e5c1665b2e48c4 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.0"], + "requirements": ["pyoverkiz==1.13.3"], "zeroconf": [ { "type": "_kizbox._tcp.local.", "name": "gateway*" + }, + { + "type": "_kizboxdev._tcp.local.", + "name": "gateway*" } ] } diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index c90c4446339287..c15a7bd3accb88 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -26,14 +26,14 @@ OPERATING_MODE_DELAY = 3 -@dataclass +@dataclass(frozen=True) class OverkizNumberDescriptionMixin: """Define an entity description mixin for number entities.""" command: str -@dataclass +@dataclass(frozen=True) class OverkizNumberDescription(NumberEntityDescription, OverkizNumberDescriptionMixin): """Class to describe an Overkiz number.""" diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index 5f72ca23a80595..c225d475f63a77 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -17,14 +17,14 @@ from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizSelectDescriptionMixin: """Define an entity description mixin for select entities.""" select_option: Callable[[str, Callable[..., Awaitable[None]]], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class OverkizSelectDescription(SelectEntityDescription, OverkizSelectDescriptionMixin): """Class to describe an Overkiz select entity.""" diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index f56643e8cd475d..3f1de4c381ee48 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -44,7 +44,7 @@ from .entity import OverkizDescriptiveEntity, OverkizEntity -@dataclass +@dataclass(frozen=True) class OverkizSensorDescription(SensorEntityDescription): """Class to describe an Overkiz sensor.""" @@ -100,7 +100,7 @@ class OverkizSensorDescription(SensorEntityDescription): 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 @@ class OverkizSensorDescription(SensorEntityDescription): 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 @@ class OverkizSensorDescription(SensorEntityDescription): 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,16 @@ def native_value(self) -> StateType: """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 + # It seems that in some cases we return `None` if state.value is falsy. + # This is probably incorrect and should be fixed in a follow up PR. + # To ensure measurement sensors do not get an `unknown` state on + # a falsy value (e.g. 0 or 0.0) we also check the state_class. + or self.state_class != SensorStateClass.MEASUREMENT + and not state.value + ): return None # Transform the value with a lambda function diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 82d29a7534a88f..a756df4d0d68f8 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 and scenarios 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", diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index b7416711e77efd..0396e385a3cfaa 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -24,7 +24,7 @@ from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizSwitchDescriptionMixin: """Define an entity description mixin for switch entities.""" @@ -32,7 +32,7 @@ class OverkizSwitchDescriptionMixin: turn_off: str -@dataclass +@dataclass(frozen=True) class OverkizSwitchDescription(SwitchEntityDescription, OverkizSwitchDescriptionMixin): """Class to describe an Overkiz switch.""" diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index b32a17f03234ba..761515c9c848f2 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +import dataclasses from datetime import datetime, timedelta from typing import Final @@ -33,7 +33,7 @@ KEY_LAST_GAS_COST: Final = "last_gas_cost" -@dataclass +@dataclasses.dataclass(frozen=True) class OVOEnergySensorEntityDescription(SensorEntityDescription): """Class describing System Bridge sensor entities.""" @@ -130,8 +130,11 @@ async def async_setup_entry( and coordinator.data.electricity[-1] is not None and coordinator.data.electricity[-1].cost is not None ): - description.native_unit_of_measurement = ( - coordinator.data.electricity[-1].cost.currency_unit + description = dataclasses.replace( + description, + native_unit_of_measurement=( + coordinator.data.electricity[-1].cost.currency_unit + ), ) entities.append(OVOEnergySensor(coordinator, description, client)) if coordinator.data.gas: @@ -141,9 +144,12 @@ async def async_setup_entry( and coordinator.data.gas[-1] is not None and coordinator.data.gas[-1].cost is not None ): - description.native_unit_of_measurement = coordinator.data.gas[ - -1 - ].cost.currency_unit + description = dataclasses.replace( + description, + native_unit_of_measurement=coordinator.data.gas[ + -1 + ].cost.currency_unit, + ) entities.append(OVOEnergySensor(coordinator, description, client)) async_add_entities(entities, True) diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json index 3ed5589e5772ca..0dfe1f3a46c0a4 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 ad74200dace24c..bcdc4195100b7b 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -5,7 +5,14 @@ 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.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_update_data() -> PECOCoordinatorData: 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 00000000000000..7f0402b207f84d --- /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 63ca7f3291ab25..144495ec0661a0 100644 --- a/homeassistant/components/peco/config_flow.py +++ b/homeassistant/components/peco/config_flow.py @@ -1,41 +1,108 @@ """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_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"} + 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=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() + if self.meter_error is not None: + # Clear any previous errors, since the user may have corrected them + self.meter_error = {} + + await self._verify_meter(phone_number) + + self.meter_data = user_input + + return await self.async_step_finish_smart_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": + 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"{county.capitalize()} Outage Count", data=user_input + 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 b0198ac87613b4..1df8ae41ecbf0d 100644 --- a/homeassistant/components/peco/const.py +++ b/homeassistant/components/peco/const.py @@ -14,6 +14,8 @@ "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 5be41f7c7e1570..f9ad35fd251937 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -24,7 +24,7 @@ from .const import ATTR_CONTENT, CONF_COUNTY, DOMAIN -@dataclass +@dataclass(frozen=True) class PECOSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -32,7 +32,7 @@ class PECOSensorEntityDescriptionMixin: attribute_fn: Callable[[PECOCoordinatorData], dict[str, str]] -@dataclass +@dataclass(frozen=True) class PECOSensorEntityDescription( SensorEntityDescription, PECOSensorEntityDescriptionMixin ): @@ -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 059b2ba71a7a99..cdf5bb497dbec1 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/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index cf229f16d122c7..5f7f431ddf7e04 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -21,14 +21,14 @@ from .entity import PegelOnlineEntity -@dataclass +@dataclass(frozen=True) class PegelOnlineRequiredKeysMixin: """Mixin for required keys.""" measurement_key: str -@dataclass +@dataclass(frozen=True) class PegelOnlineSensorEntityDescription( SensorEntityDescription, PegelOnlineRequiredKeysMixin ): diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py new file mode 100644 index 00000000000000..2f3c4c04c50254 --- /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 00000000000000..644ea29d8a3c3e --- /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 00000000000000..fd5fe673f2a72e --- /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 00000000000000..3695236cdf09ea --- /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 00000000000000..fd937fc6f8a54d --- /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 00000000000000..8a504248f5ac80 --- /dev/null +++ b/homeassistant/components/permobil/sensor.py @@ -0,0 +1,247 @@ +"""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_DISTANCE, + RECORDS_DISTANCE_UNIT, + 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, KM, MILES +from .coordinator import MyPermobilCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PermobilRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Any], float | int] + available_fn: Callable[[Any], bool] + + +@dataclass(frozen=True) +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, monotonically increasing, 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, + ), + PermobilSensorEntityDescription( + # Record of largest distance travelled in a day, monotonically increasing, never resets + value_fn=lambda data: data.records[RECORDS_DISTANCE[0]], + available_fn=lambda data: RECORDS_DISTANCE[0] in data.records, + key="record_distance", + translation_key="record_distance", + icon="mdi:map-marker-distance", + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + +DISTANCE_UNITS: dict[Any, UnitOfLength] = { + KM: UnitOfLength.KILOMETERS, + MILES: UnitOfLength.MILES, +} + + +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 native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor.""" + if self.entity_description.key == "record_distance": + return DISTANCE_UNITS.get( + self.coordinator.data.records[RECORDS_DISTANCE_UNIT[0]] + ) + return self.entity_description.native_unit_of_measurement + + @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 00000000000000..b500bbdb9ea8cf --- /dev/null +++ b/homeassistant/components/permobil/strings.json @@ -0,0 +1,73 @@ +{ + "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" + }, + "record_distance": { + "name": "Record distance" + } + } + } +} diff --git a/homeassistant/components/persistent_notification/strings.json b/homeassistant/components/persistent_notification/strings.json index 5f256233149941..ca89a4d33cd630 100644 --- a/homeassistant/components/persistent_notification/strings.json +++ b/homeassistant/components/persistent_notification/strings.json @@ -1,4 +1,5 @@ { + "title": "Persistent Notification", "services": { "create": { "name": "Create", diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 49b719a549032a..c796cb8d843cca 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -1,9 +1,11 @@ """Support for tracking people.""" from __future__ import annotations +from http import HTTPStatus import logging from typing import Any +from aiohttp import web import voluptuous as vol from homeassistant.auth import EVENT_USER_REMOVED @@ -13,6 +15,7 @@ DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, ) +from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import ( ATTR_EDITABLE, ATTR_ENTITY_ID, @@ -385,6 +388,8 @@ async def async_reload_yaml(call: ServiceCall) -> None: hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml ) + hass.http.register_view(ListPersonsView) + return True @@ -569,3 +574,19 @@ 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.""" + return self.json_message( + message="Not local", + status_code=HTTPStatus.BAD_REQUEST, + message_code="not_local", + ) diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json index f6682058dae81d..7f370be6fbe81b 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 969c6c7b837d94..b81fec90a59ed0 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -36,6 +36,7 @@ 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 00000000000000..74fe41bf722666 --- /dev/null +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -0,0 +1,104 @@ +"""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(frozen=True, kw_only=True) +class PhilipsTVBinarySensorEntityDescription(BinarySensorEntityDescription): + """A entity description for Philips TV binary sensor.""" + + recording_value: str + + +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 6c738a36df31d4..3ea632ce436df3 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/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 5d1419db8b2e01..2f3a5a4801cb2a 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -21,14 +21,14 @@ from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN -@dataclass +@dataclass(frozen=True) class RequiredPiHoleBinaryDescription: """Represent the required attributes of the PiHole binary description.""" state_value: Callable[[Hole], bool] -@dataclass +@dataclass(frozen=True) class PiHoleBinarySensorEntityDescription( BinarySensorEntityDescription, RequiredPiHoleBinaryDescription ): diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index b9d8bf828d429c..b559a1cf806386 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -17,7 +17,7 @@ from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN -@dataclass +@dataclass(frozen=True) class PiHoleUpdateEntityDescription(UpdateEntityDescription): """Describes PiHole update entity.""" diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index ec7f6e154254e6..d2f023af79f4c8 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -3,14 +3,14 @@ from python_picnic_api import PicnicAPI from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform from homeassistant.core import HomeAssistant -from .const import CONF_API, CONF_COORDINATOR, CONF_COUNTRY_CODE, DOMAIN +from .const import CONF_API, CONF_COORDINATOR, 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/config_flow.py b/homeassistant/components/picnic/config_flow.py index 65ae201482acb5..b02c0a74bfce55 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -12,10 +12,15 @@ from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_COUNTRY_CODE, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.data_entry_flow import FlowResult -from .const import CONF_COUNTRY_CODE, COUNTRY_CODES, DOMAIN +from .const import COUNTRY_CODES, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 7e983321f3d618..851df6f41b25f7 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -5,7 +5,6 @@ CONF_API = "api" CONF_COORDINATOR = "coordinator" -CONF_COUNTRY_CODE = "country_code" SERVICE_ADD_PRODUCT_TO_CART = "add_product" @@ -15,7 +14,7 @@ ATTR_AMOUNT = "amount" ATTR_PRODUCT_IDENTIFIERS = "product_identifiers" -COUNTRY_CODES = ["NL", "DE", "BE"] +COUNTRY_CODES = ["NL", "DE", "BE", "FR"] ATTRIBUTION = "Data provided by Picnic" ADDRESS = "address" CART_DATA = "cart_data" diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index e7a69e0bf0294e..56d2d22cf29e5b 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -17,10 +17,7 @@ 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,9 +41,10 @@ SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, SENSOR_SELECTED_SLOT_START, ) +from .coordinator import PicnicUpdateCoordinator -@dataclass +@dataclass(frozen=True) class PicnicRequiredKeysMixin: """Mixin for required keys.""" @@ -56,7 +54,7 @@ class PicnicRequiredKeysMixin: value_fn: Callable[[Any], StateType | datetime] -@dataclass +@dataclass(frozen=True) class PicnicSensorEntityDescription(SensorEntityDescription, PicnicRequiredKeysMixin): """Describes Picnic sensor entity.""" @@ -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 3af2a521f8ad0e..b44d4dd5a62538 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 0fd107609d1287..9a6b7162fd57bb 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 00000000000000..fea99f7403dd5c --- /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 26dd8113231a1e..81df1401f91109 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -4,18 +4,22 @@ 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 b120c453195bb4..9763611158666e 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 @@ 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.""" + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_IMPORTED_BY: "binary_sensor", **config}, + ) + ) + + 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", + }, + ) + + +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] - 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_add_entities([PingBinarySensor(entry, data.coordinators[entry.entry_id])]) -class PingBinarySensor(RestoreEntity, BinarySensorEntity): +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 00000000000000..29b8a8ba2a5ead --- /dev/null +++ b/homeassistant/components/ping/config_flow.py @@ -0,0 +1,124 @@ +"""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.components.device_tracker import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, +) +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, + CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME.seconds, + }, + ) + + 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], + CONF_CONSIDER_HOME: import_info.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME + ).seconds, + } + 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 + ) + ), + vol.Optional( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.seconds + ), + ): int, + } + ), + ) diff --git a/homeassistant/components/ping/const.py b/homeassistant/components/ping/const.py index fd70a9340c250c..6ee53ea3d2291f 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 @@ 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 00000000000000..f6bda9693b8170 --- /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(seconds=30), + ) + + 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 9a63a2f844d753..6b904043b30249 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -1,39 +1,47 @@ """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, + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, 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.helpers.update_coordinator import CoordinatorEntity 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 . 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 +50,137 @@ ) -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.""" - data: PingDomainData = hass.data[DOMAIN] + 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" + ) - 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()), - ) - - 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), + devices: dict[str, dict[str, Any]] = {} + try: + devices = await hass.async_add_executor_job( + load_yaml_config_file, hass.config.path(YAML_DEVICES) ) - await asyncio.gather( - *( - async_see(dev_id=host.dev_id, source_type=SourceType.ROUTER) - for idx, host in enumerate(hosts) - if results[idx] - ) + except (FileNotFoundError, HomeAssistantError): + _LOGGER.debug( + "No valid known_devices.yaml found, " + "skip removal of devices from known_devices.yaml" ) - else: - - 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 + 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], + CONF_CONSIDER_HOME: config[CONF_CONSIDER_HOME], + }, ) ) - 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 - ) + 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) - await _async_update_interval(dt_util.now()) 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] + + async_add_entities([PingDeviceTracker(entry, data.coordinators[entry.entry_id])]) + + +class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): + """Representation of a Ping device tracker.""" + + _last_seen: datetime | None = None + + def __init__( + self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + ) -> None: + """Initialize the Ping device tracker.""" + super().__init__(coordinator) + + self._attr_name = config_entry.title + self.config_entry = config_entry + self._consider_home_interval = timedelta( + seconds=config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.seconds + ) + ) + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self.coordinator.data.ip_address + + @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 or considered home.""" + if self.coordinator.data.is_alive: + self._last_seen = dt_util.utcnow() + + return ( + self._last_seen is not None + and (dt_util.utcnow() - self._last_seen) < self._consider_home_interval + ) + + @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 da58858a801579..ce3d5c3b461d04 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 @@ def __init__( 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 @@ def __init__( "-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 e27c3a239d00c3..ded5a3fd3e6714 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 c983a105c93977..00000000000000 --- 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 5b5c5da46bc1d3..421d9079c62400 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -1,8 +1,37 @@ { - "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%]" + }, + "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": "Ping count", + "consider_home": "Consider home interval" + }, + "data_description": { + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen." + } + } + }, + "abort": { + "invalid_ip_address": "[%key:component::ping::config::abort::invalid_ip_address%]" } } } diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py index 985b4ccb4e95fa..24bc09bac42c08 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -9,12 +9,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_SERVER, - CONF_SERVER_IDENTIFIER, - DOMAIN, - PLEX_UPDATE_PLATFORMS_SIGNAL, -) +from . import PlexServer +from .const import CONF_SERVER_IDENTIFIER, DOMAIN, PLEX_UPDATE_PLATFORMS_SIGNAL +from .helpers import get_plex_server async def async_setup_entry( @@ -24,22 +21,24 @@ async def async_setup_entry( ) -> None: """Set up Plex button from config entry.""" server_id: str = config_entry.data[CONF_SERVER_IDENTIFIER] - server_name: str = config_entry.data[CONF_SERVER] - async_add_entities([PlexScanClientsButton(server_id, server_name)]) + plex_server = get_plex_server(hass, server_id) + async_add_entities([PlexScanClientsButton(server_id, plex_server)]) class PlexScanClientsButton(ButtonEntity): """Representation of a scan_clients button entity.""" _attr_entity_category = EntityCategory.CONFIG + _attr_has_entity_name = True + _attr_translation_key = "scan_clients" - def __init__(self, server_id: str, server_name: str) -> None: + def __init__(self, server_id: str, plex_server: PlexServer) -> None: """Initialize a scan_clients Plex button entity.""" self.server_id = server_id - self._attr_name = f"Scan Clients ({server_name})" self._attr_unique_id = f"plex-scan_clients-{self.server_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, server_id)}, + name=plex_server.friendly_name, manufacturer="Plex", ) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index a11d2d865c2570..8fc01140787d6f 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.4", + "PlexAPI==4.15.7", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 3e6875f98b9edb..3e817b4ea1a52b 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -53,7 +53,7 @@ def needs_session( - func: Callable[Concatenate[_PlexMediaPlayerT, _P], _R] + func: Callable[Concatenate[_PlexMediaPlayerT, _P], _R], ) -> Callable[Concatenate[_PlexMediaPlayerT, _P], _R | None]: """Ensure session is available for certain attributes.""" diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 972cd8d4bc9e45..acc309ab14c781 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -17,7 +17,6 @@ from .const import ( CONF_SERVER_IDENTIFIER, DOMAIN, - NAME_FORMAT, PLEX_UPDATE_LIBRARY_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, ) @@ -71,13 +70,15 @@ def create_library_sensors(): class PlexSensor(SensorEntity): """Representation of a Plex now playing sensor.""" + _attr_has_entity_name = True + _attr_name = None + _attr_icon = "mdi:plex" + _attr_should_poll = False + _attr_native_unit_of_measurement = "watching" + def __init__(self, hass, plex_server): """Initialize the sensor.""" - self._attr_icon = "mdi:plex" - self._attr_name = NAME_FORMAT.format(plex_server.friendly_name) - self._attr_should_poll = False self._attr_unique_id = f"sensor-{plex_server.machine_identifier}" - self._attr_native_unit_of_measurement = "Watching" self._server = plex_server self.async_refresh_sensor = Debouncer( @@ -113,9 +114,6 @@ def extra_state_attributes(self): @property def device_info(self) -> DeviceInfo | None: """Return a device description for device registry.""" - if self.unique_id is None: - return None - return DeviceInfo( identifiers={(DOMAIN, self._server.machine_identifier)}, manufacturer="Plex", diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 9cba83653fd1ad..4f5ca3f2bc4088 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -57,6 +57,13 @@ } } }, + "entity": { + "button": { + "scan_clients": { + "name": "Scan clients" + } + } + }, "services": { "refresh_library": { "name": "Refresh library", diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 5da82ab4105fb6..0c67e20d7ab470 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -23,7 +23,7 @@ SEVERITIES = ["other", "info", "warning", "error"] -@dataclass +@dataclass(frozen=True) class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Plugwise binary sensor entity.""" diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index a33cef0e3a7713..84e0619773b58d 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 @@ def __init__( 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 @@ def __init__( 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,56 @@ def target_temperature_low(self) -> float: @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) + + # Adam provides the hvac_action for each thermostat + if (control_state := self.device.get("control_state")) == "cooling": + return HVACAction.COOLING + if control_state == "heating": + return HVACAction.HEATING + if control_state == "preheating": + return HVACAction.PREHEATING + if control_state == "off": + return HVACAction.IDLE + + heater: str = self.coordinator.data.gateway["heater_id"] + heater_data = self.coordinator.data.devices[heater] + if heater_data["binary_sensors"]["heating_state"]: + return HVACAction.HEATING + if heater_data["binary_sensors"].get("cooling_state", False): + return HVACAction.COOLING return HVACAction.IDLE @@ -168,9 +212,18 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: 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/const.py b/homeassistant/components/plugwise/const.py index 34bb5c926aef99..f5677c0b4a9d7c 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from typing import Final +from typing import Final, Literal from homeassistant.const import Platform @@ -36,6 +36,23 @@ "stretch": "Stretch", } +NumberType = Literal[ + "maximum_boiler_temperature", + "max_dhw_temperature", + "temperature_offset", +] + +SelectType = Literal[ + "select_dhw_mode", + "select_regulation_mode", + "select_schedule", +] +SelectOptionsType = Literal[ + "dhw_modes", + "regulation_modes", + "available_schedules", +] + # Default directives DEFAULT_MAX_TEMP: Final = 30 DEFAULT_MIN_TEMP: Final = 4 diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 1155aaffdf860c..92923e98d2c23a 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.35.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 9865aec22428e2..c71b52cf5c8ea4 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from plugwise import Smile -from plugwise.constants import NumberType from homeassistant.components.number import ( NumberDeviceClass, @@ -18,24 +17,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, NumberType 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(frozen=True, 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 138e5fe3b5932d..4be21fe902615c 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from plugwise import Smile -from plugwise.constants import SelectOptionsType, SelectType from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -13,26 +12,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, SelectOptionsType, SelectType 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(frozen=True, 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/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 0cc878178feca7..95dfc2ba6a3eef 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -32,7 +32,7 @@ from .entity import PlugwiseEntity -@dataclass +@dataclass(frozen=True) class PlugwiseSensorEntityDescription(SensorEntityDescription): """Describes Plugwise sensor entity.""" diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 5348a1dc4840cf..addd1ceadb156e 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -108,7 +108,10 @@ } }, "select_schedule": { - "name": "Thermostat schedule" + "name": "Thermostat schedule", + "state": { + "off": "Off" + } } }, "sensor": { diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 8639826e37a7e8..dfd11127332677 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -22,7 +22,7 @@ from .util import plugwise_command -@dataclass +@dataclass(frozen=True) class PlugwiseSwitchEntityDescription(SwitchEntityDescription): """Describes Plugwise switch entity.""" diff --git a/homeassistant/components/plugwise/util.py b/homeassistant/components/plugwise/util.py index 2abb1051d7420a..4f8d4c8d8fe289 100644 --- a/homeassistant/components/plugwise/util.py +++ b/homeassistant/components/plugwise/util.py @@ -14,7 +14,7 @@ def plugwise_command( - func: Callable[Concatenate[_PlugwiseEntityT, _P], Awaitable[_R]] + func: Callable[Concatenate[_PlugwiseEntityT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_PlugwiseEntityT, _P], Coroutine[Any, Any, _R]]: """Decorate Plugwise calls that send commands/make changes to the device. diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 462d8270f0a47c..471fa72c6c5549 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -23,14 +23,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class MinutPointRequiredKeysMixin: """Mixin for required keys.""" precision: int -@dataclass +@dataclass(frozen=True) class MinutPointSensorEntityDescription( SensorEntityDescription, MinutPointRequiredKeysMixin ): diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 33395f5fe6ab7f..8587101a42a5c1 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -122,9 +122,9 @@ def _update_data(self) -> PowerwallData: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" http_session = requests.Session() - ip_address = entry.data[CONF_IP_ADDRESS] + ip_address: str = entry.data[CONF_IP_ADDRESS] - password = entry.data.get(CONF_PASSWORD) + password: str | None = entry.data.get(CONF_PASSWORD) power_wall = Powerwall(ip_address, http_session=http_session) try: base_info = await hass.async_add_executor_job( @@ -184,7 +184,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _login_and_fetch_base_info( - power_wall: Powerwall, host: str, password: str + power_wall: Powerwall, host: str, password: str | None ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" if password is not None: diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 3f02c925f9d9ec..bfa75392efb3c9 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -32,14 +32,14 @@ _METER_DIRECTION_IMPORT = "import" -@dataclass +@dataclass(frozen=True) class PowerwallRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Meter], float] -@dataclass +@dataclass(frozen=True) class PowerwallSensorEntityDescription( SensorEntityDescription, PowerwallRequiredKeysMixin ): diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 663461ceaa1f12..bc4ad0f291295c 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.19.0"] } diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index b332d057ba9b06..fb094de3d58c72 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -26,7 +26,7 @@ from .entity import BasePrivateDeviceEntity -@dataclass +@dataclass(frozen=True) class PrivateDeviceSensorEntityDescriptionRequired: """Required domain specific fields for sensor entity.""" @@ -35,7 +35,7 @@ class PrivateDeviceSensorEntityDescriptionRequired: ] -@dataclass +@dataclass(frozen=True) class PrivateDeviceSensorEntityDescription( SensorEntityDescription, PrivateDeviceSensorEntityDescriptionRequired ): @@ -83,13 +83,17 @@ class PrivateDeviceSensorEntityDescription( 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/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 8c5c206ae9ff33..5e4408bba20be6 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -11,7 +11,7 @@ import traceback from typing import Any, cast -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU import voluptuous as vol from homeassistant.components import persistent_notification diff --git a/homeassistant/components/progettihwsw/strings.json b/homeassistant/components/progettihwsw/strings.json index bb98d565594e48..d50c6f8d4e390f 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 c96ed2e4ed3591..7beac4cc54b534 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -19,6 +19,7 @@ from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY +from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -323,14 +324,14 @@ def _labels(state): } def _battery(self, state): - if "battery_level" in state.attributes: + if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is not None: metric = self._metric( "battery_level_percent", self.prometheus_cli.Gauge, "Battery level as a percentage of its capacity", ) try: - value = float(state.attributes[ATTR_BATTERY_LEVEL]) + value = float(battery_level) metric.labels(**self._labels(state)).set(value) except ValueError: pass @@ -353,18 +354,18 @@ def _handle_input_boolean(self, state): 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): @@ -378,6 +379,12 @@ def _handle_input_number(self, state): ) 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", @@ -434,8 +441,9 @@ def _handle_light(self, state): ) try: - if "brightness" in state.attributes and state.state == STATE_ON: - value = state.attributes["brightness"] / 255.0 + brightness = state.attributes.get(ATTR_BRIGHTNESS) + if state.state == STATE_ON and brightness is not None: + value = brightness / 255.0 else: value = self.state_as_number(state) value = value * 100 diff --git a/homeassistant/components/prosegur/__init__.py b/homeassistant/components/prosegur/__init__.py index 9f594fc6dae7c2..fd79a091e39ad8 100644 --- a/homeassistant/components/prosegur/__init__.py +++ b/homeassistant/components/prosegur/__init__.py @@ -4,12 +4,12 @@ from pyprosegur.auth import Auth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import CONF_COUNTRY, DOMAIN +from .const import DOMAIN PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index ac2b704b012ef0..c28245a09ff06f 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -9,11 +9,11 @@ from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, selector -from .const import CONF_CONTRACT, CONF_COUNTRY, DOMAIN +from .const import CONF_CONTRACT, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/prosegur/const.py b/homeassistant/components/prosegur/const.py index ea823e760621e6..495bec5d4ca560 100644 --- a/homeassistant/components/prosegur/const.py +++ b/homeassistant/components/prosegur/const.py @@ -2,7 +2,6 @@ DOMAIN = "prosegur" -CONF_COUNTRY = "country" CONF_CONTRACT = "contract" SERVICE_REQUEST_IMAGE = "request_image" diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 23a8fc3bf649ab..4012d6e8ea1e71 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -5,46 +5,27 @@ 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 @@ ) -@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 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(): + _LOGGER.debug("setup %s with config:%s", zone, proximity_config) - async_track_state_change( - hass, proximity_devices, proximity.async_check_proximity_state_change - ) + coordinator = ProximityDataUpdateCoordinator(hass, zone, proximity_config) - return True + async_track_state_change( + hass, + proximity_config[CONF_DEVICES], + coordinator.async_check_proximity_state_change, + ) + await coordinator.async_refresh() + hass.data[DOMAIN][zone] = coordinator -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Get the zones and offsets from configuration.yaml.""" - for zone, proximity_config in config[DOMAIN].items(): - async_setup_proximity_component(hass, zone, proximity_config) + 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}" - @property - def name(self) -> str: - """Return the name of the entity.""" - return self.friendly_name + self._attr_name = friendly_name + self._attr_unit_of_measurement = self.coordinator.unit_of_measurement @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 00000000000000..a5cee0ffce33cc --- /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 00000000000000..1f1c96c9490c12 --- /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 c09a03b2438ff5..3f1ea950d0e3f3 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/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index e81901dad524b5..b6a00bbaf10566 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -6,13 +6,22 @@ from datetime import timedelta import logging from time import monotonic -from typing import Generic, TypeVar +from typing import TypeVar -from pyprusalink import InvalidAuth, JobInfo, PrinterInfo, PrusaLink, PrusaLinkError +from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink +from pyprusalink.types import InvalidAuth, PrusaLinkError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -21,6 +30,7 @@ UpdateFailed, ) +from .config_flow import ConfigFlow from .const import DOMAIN PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.CAMERA, Platform.SENSOR] @@ -29,14 +39,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up PrusaLink from a config entry.""" + if entry.version == 1 and entry.minor_version < 2: + raise ConfigEntryError("Please upgrade your printer's firmware.") + api = PrusaLink( async_get_clientsession(hass), - entry.data["host"], - entry.data["api_key"], + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], ) coordinators = { - "printer": PrinterUpdateCoordinator(hass, api), + "legacy_status": LegacyStatusCoordinator(hass, api), + "status": StatusCoordinator(hass, api), "job": JobUpdateCoordinator(hass, api), } for coordinator in coordinators.values(): @@ -49,6 +64,62 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + if config_entry.version > ConfigFlow.VERSION: + # This means the user has downgraded from a future version + return False + + new_data = dict(config_entry.data) + if config_entry.version == 1: + if config_entry.minor_version < 2: + # Add username and password + # "maker" is currently hardcoded in the firmware + # https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19 + username = "maker" + password = config_entry.data[CONF_API_KEY] + + api = PrusaLink( + async_get_clientsession(hass), + config_entry.data[CONF_HOST], + username, + password, + ) + try: + await api.get_info() + except InvalidAuth: + # We are unable to reach the new API which usually means + # that the user is running an outdated firmware version + ir.async_create_issue( + hass, + DOMAIN, + "firmware_5_1_required", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="firmware_5_1_required", + translation_placeholders={ + "entry_title": config_entry.title, + "prusa_mini_firmware_update": "https://help.prusa3d.com/article/firmware-updating-mini-mini_124784", + "prusa_mk4_xl_firmware_update": "https://help.prusa3d.com/article/how-to-update-firmware-mk4-xl_453086", + }, + ) + # There is a check in the async_setup_entry to prevent the setup if minor_version < 2 + # Currently we can't reload the config entry + # if the migration returns False. + # Return True here to workaround that. + return True + + new_data[CONF_USERNAME] = username + new_data[CONF_PASSWORD] = password + + ir.async_delete_issue(hass, DOMAIN, "firmware_5_1_required") + config_entry.minor_version = 2 + + hass.config_entries.async_update_entry(config_entry, data=new_data) + + 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): @@ -57,10 +128,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -T = TypeVar("T", PrinterInfo, JobInfo) +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) -class PrusaLinkUpdateCoordinator(DataUpdateCoordinator, Generic[T], ABC): +class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): """Update coordinator for the printer.""" config_entry: ConfigEntry @@ -105,21 +176,20 @@ def _get_update_interval(self, data: T) -> timedelta: return timedelta(seconds=30) -class PrinterUpdateCoordinator(PrusaLinkUpdateCoordinator[PrinterInfo]): +class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): """Printer update coordinator.""" - async def _fetch_data(self) -> PrinterInfo: + async def _fetch_data(self) -> PrinterStatus: """Fetch the printer data.""" - return await self.api.get_printer() + return await self.api.get_status() - def _get_update_interval(self, data: T) -> timedelta: - """Get new update interval.""" - if data and any( - data["state"]["flags"][key] for key in ("pausing", "cancelling") - ): - return timedelta(seconds=5) - return super()._get_update_interval(data) +class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): + """Printer legacy update coordinator.""" + + async def _fetch_data(self) -> LegacyPrinterStatus: + """Fetch the printer data.""" + return await self.api.get_legacy_printer() class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]): @@ -142,5 +212,5 @@ def device_info(self) -> DeviceInfo: identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, name=self.coordinator.config_entry.title, manufacturer="Prusa", - configuration_url=self.coordinator.api.host, + configuration_url=self.coordinator.api.client.host, ) diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index 7e95b209bad950..8f8a62794a9144 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -5,7 +5,8 @@ from dataclasses import dataclass from typing import Any, Generic, TypeVar, cast -from pyprusalink import Conflict, JobInfo, PrinterInfo, PrusaLink +from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink +from pyprusalink.types import Conflict, PrinterState from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry @@ -15,17 +16,17 @@ from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator -T = TypeVar("T", PrinterInfo, JobInfo) +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) -@dataclass +@dataclass(frozen=True) class PrusaLinkButtonEntityDescriptionMixin(Generic[T]): """Mixin for required keys.""" - press_fn: Callable[[PrusaLink], Coroutine[Any, Any, None]] + press_fn: Callable[[PrusaLink], Callable[[int], Coroutine[Any, Any, None]]] -@dataclass +@dataclass(frozen=True) class PrusaLinkButtonEntityDescription( ButtonEntityDescription, PrusaLinkButtonEntityDescriptionMixin[T], Generic[T] ): @@ -35,33 +36,34 @@ class PrusaLinkButtonEntityDescription( BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { - "printer": ( - PrusaLinkButtonEntityDescription[PrinterInfo]( + "status": ( + PrusaLinkButtonEntityDescription[PrinterStatus]( key="printer.cancel_job", translation_key="cancel_job", icon="mdi:cancel", - press_fn=lambda api: cast(Coroutine, api.cancel_job()), - available_fn=lambda data: any( - data["state"]["flags"][flag] - for flag in ("printing", "pausing", "paused") + press_fn=lambda api: api.cancel_job, + available_fn=lambda data: ( + data["printer"]["state"] + in [PrinterState.PRINTING.value, PrinterState.PAUSED.value] ), ), - PrusaLinkButtonEntityDescription[PrinterInfo]( + PrusaLinkButtonEntityDescription[PrinterStatus]( key="job.pause_job", translation_key="pause_job", icon="mdi:pause", - press_fn=lambda api: cast(Coroutine, api.pause_job()), - available_fn=lambda data: ( - data["state"]["flags"]["printing"] - and not data["state"]["flags"]["paused"] + press_fn=lambda api: api.pause_job, + available_fn=lambda data: cast( + bool, data["printer"]["state"] == PrinterState.PRINTING.value ), ), - PrusaLinkButtonEntityDescription[PrinterInfo]( + PrusaLinkButtonEntityDescription[PrinterStatus]( key="job.resume_job", translation_key="resume_job", icon="mdi:play", - press_fn=lambda api: cast(Coroutine, api.resume_job()), - available_fn=lambda data: cast(bool, data["state"]["flags"]["paused"]), + press_fn=lambda api: api.resume_job, + available_fn=lambda data: cast( + bool, data["printer"]["state"] == PrinterState.PAUSED.value + ), ), ), } @@ -113,8 +115,10 @@ def available(self) -> bool: async def async_press(self) -> None: """Press the button.""" + job_id = self.coordinator.data["job"]["id"] + func = self.entity_description.press_fn(self.coordinator.api) try: - await self.entity_description.press_fn(self.coordinator.api) + await func(job_id) except Conflict as err: raise HomeAssistantError( "Action conflicts with current printer state" diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index a8b8f387effb64..7f6fab0583b265 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -35,7 +35,11 @@ def __init__(self, coordinator: JobUpdateCoordinator) -> None: @property def available(self) -> bool: """Get if camera is available.""" - return super().available and self.coordinator.data.get("job") is not None + return ( + super().available + and (file := self.coordinator.data.get("file")) + and file.get("refs", {}).get("thumbnail") + ) async def async_camera_image( self, width: int | None = None, height: int | None = None @@ -44,11 +48,11 @@ async def async_camera_image( if not self.available: return None - path = self.coordinator.data["job"]["file"]["path"] + path = self.coordinator.data["file"]["refs"]["thumbnail"] if self.last_path == path: return self.last_image - self.last_image = await self.coordinator.api.get_large_thumbnail(path) + self.last_image = await self.coordinator.api.get_file(path) self.last_path = path return self.last_image diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index b1faad6e3ea385..378c5e7395a873 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -7,11 +7,12 @@ from aiohttp import ClientError from awesomeversion import AwesomeVersion, AwesomeVersionException -from pyprusalink import InvalidAuth, PrusaLink +from pyprusalink import PrusaLink +from pyprusalink.types import InvalidAuth import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -25,7 +26,10 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, - vol.Required(CONF_API_KEY): str, + # "maker" is currently hardcoded in the firmware + # https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19 + vol.Required(CONF_USERNAME, default="maker"): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -35,7 +39,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - api = PrusaLink(async_get_clientsession(hass), data[CONF_HOST], data[CONF_API_KEY]) + api = PrusaLink( + async_get_clientsession(hass), + data[CONF_HOST], + data[CONF_USERNAME], + data[CONF_PASSWORD], + ) try: async with asyncio.timeout(5): @@ -58,6 +67,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PrusaLink.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -74,7 +84,8 @@ async def async_step_user( data = { CONF_HOST: host, - CONF_API_KEY: user_input[CONF_API_KEY], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], } errors = {} diff --git a/homeassistant/components/prusalink/manifest.json b/homeassistant/components/prusalink/manifest.json index ade39320a296bf..a9d8353690ebbc 100644 --- a/homeassistant/components/prusalink/manifest.json +++ b/homeassistant/components/prusalink/manifest.json @@ -1,7 +1,7 @@ { "domain": "prusalink", "name": "PrusaLink", - "codeowners": ["@balloob"], + "codeowners": ["@balloob", "@Skaronator"], "config_flow": true, "dhcp": [ { @@ -10,5 +10,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/prusalink", "iot_class": "local_polling", - "requirements": ["pyprusalink==1.1.0"] + "requirements": ["pyprusalink==2.0.0"] } diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index 1ee4274e5bb021..29e1d5c9757730 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -6,7 +6,8 @@ from datetime import datetime, timedelta from typing import Generic, TypeVar, cast -from pyprusalink import JobInfo, PrinterInfo +from pyprusalink.types import JobInfo, PrinterState, PrinterStatus +from pyprusalink.types_legacy import LegacyPrinterStatus from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,7 +16,12 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfLength, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -24,17 +30,17 @@ from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator -T = TypeVar("T", PrinterInfo, JobInfo) +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) -@dataclass +@dataclass(frozen=True) class PrusaLinkSensorEntityDescriptionMixin(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T], datetime | StateType] -@dataclass +@dataclass(frozen=True) class PrusaLinkSensorEntityDescription( SensorEntityDescription, PrusaLinkSensorEntityDescriptionMixin[T], Generic[T] ): @@ -44,78 +50,91 @@ class PrusaLinkSensorEntityDescription( SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { - "printer": ( - PrusaLinkSensorEntityDescription[PrinterInfo]( + "status": ( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.state", name=None, icon="mdi:printer-3d", - value_fn=lambda data: ( - "pausing" - if (flags := data["state"]["flags"])["pausing"] - else "cancelling" - if flags["cancelling"] - else "paused" - if flags["paused"] - else "printing" - if flags["printing"] - else "idle" - ), + value_fn=lambda data: (cast(str, data["printer"]["state"].lower())), device_class=SensorDeviceClass.ENUM, - options=["cancelling", "idle", "paused", "pausing", "printing"], + options=[state.value.lower() for state in PrinterState], translation_key="printer_state", ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-bed", translation_key="heatbed_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["telemetry"]["temp-bed"]), + value_fn=lambda data: cast(float, data["printer"]["temp_bed"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-nozzle", translation_key="nozzle_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["telemetry"]["temp-nozzle"]), + value_fn=lambda data: cast(float, data["printer"]["temp_nozzle"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-bed.target", translation_key="heatbed_target_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["temperature"]["bed"]["target"]), + value_fn=lambda data: cast(float, data["printer"]["target_bed"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-nozzle.target", translation_key="nozzle_target_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["temperature"]["tool0"]["target"]), + value_fn=lambda data: cast(float, data["printer"]["target_nozzle"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.z-height", translation_key="z_height", native_unit_of_measurement=UnitOfLength.MILLIMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["telemetry"]["z-height"]), + value_fn=lambda data: cast(float, data["printer"]["axis_z"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.print-speed", translation_key="print_speed", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(float, data["telemetry"]["print-speed"]), + value_fn=lambda data: cast(float, data["printer"]["speed"]), + ), + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.print-flow", + translation_key="print_flow", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(float, data["printer"]["flow"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.fan-hotend", + translation_key="fan_hotend", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + value_fn=lambda data: cast(float, data["printer"]["fan_hotend"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.fan-print", + translation_key="fan_print", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + value_fn=lambda data: cast(float, data["printer"]["fan_print"]), + entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + ), + "legacy_status": ( + PrusaLinkSensorEntityDescription[LegacyPrinterStatus]( key="printer.telemetry.material", translation_key="material", icon="mdi:palette-swatch-variant", @@ -128,15 +147,15 @@ class PrusaLinkSensorEntityDescription( translation_key="progress", icon="mdi:progress-clock", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(float, data["progress"]["completion"]) * 100, + value_fn=lambda data: cast(float, data["progress"]), available_fn=lambda data: data.get("progress") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.filename", translation_key="filename", icon="mdi:file-image-outline", - value_fn=lambda data: cast(str, data["job"]["file"]["display"]), - available_fn=lambda data: data.get("job") is not None, + value_fn=lambda data: cast(str, data["file"]["display_name"]), + available_fn=lambda data: data.get("file") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.start", @@ -144,12 +163,10 @@ class PrusaLinkSensorEntityDescription( device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock-start", value_fn=ignore_variance( - lambda data: ( - utcnow() - timedelta(seconds=data["progress"]["printTime"]) - ), + lambda data: (utcnow() - timedelta(seconds=data["time_printing"])), timedelta(minutes=2), ), - available_fn=lambda data: data.get("progress") is not None, + available_fn=lambda data: data.get("time_printing") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.finish", @@ -157,12 +174,10 @@ class PrusaLinkSensorEntityDescription( icon="mdi:clock-end", device_class=SensorDeviceClass.TIMESTAMP, value_fn=ignore_variance( - lambda data: ( - utcnow() + timedelta(seconds=data["progress"]["printTimeLeft"]) - ), + lambda data: (utcnow() + timedelta(seconds=data["time_remaining"])), timedelta(minutes=2), ), - available_fn=lambda data: data.get("progress") is not None, + available_fn=lambda data: data.get("time_remaining") is not None, ), ), } diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index aa992b4874f0b3..bb32770e357c56 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -4,7 +4,8 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" } } }, @@ -15,15 +16,25 @@ "not_supported": "Only PrusaLink API v2 is supported" } }, + "issues": { + "firmware_5_1_required": { + "description": "The PrusaLink integration has been updated to utilize the latest v1 API endpoints, which require firmware version 4.7.0 or later. If you own a Prusa Mini, please make sure your printer is running firmware 5.1.0 or a more recent version, as firmware versions 4.7.x and 5.0.x are not available for this model.\n\nFollow the guide below to update your {entry_title}.\n* [Prusa Mini Firmware Update]({prusa_mini_firmware_update})\n* [Prusa MK4/XL Firmware Update]({prusa_mk4_xl_firmware_update})\n\nAfter you've updated your printer's firmware, make sure to reload the config entry to fix this issue.", + "title": "Firmware update required" + } + }, "entity": { "sensor": { "printer_state": { "state": { - "cancelling": "Cancelling", "idle": "[%key:common::state::idle%]", + "busy": "Busy", + "printing": "Printing", "paused": "[%key:common::state::paused%]", - "pausing": "Pausing", - "printing": "Printing" + "finished": "Finished", + "stopped": "Stopped", + "error": "Error", + "attention": "Attention", + "ready": "Ready" } }, "heatbed_temperature": { @@ -56,6 +67,15 @@ "print_speed": { "name": "Print speed" }, + "print_flow": { + "name": "Print flow" + }, + "fan_hotend": { + "name": "Hotend fan" + }, + "fan_print": { + "name": "Print fan" + }, "z_height": { "name": "Z-Height" } diff --git a/homeassistant/components/psoklahoma/__init__.py b/homeassistant/components/psoklahoma/__init__.py new file mode 100644 index 00000000000000..a0a3a4ca0bb016 --- /dev/null +++ b/homeassistant/components/psoklahoma/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Public Service Company of Oklahoma (PSO).""" diff --git a/homeassistant/components/psoklahoma/manifest.json b/homeassistant/components/psoklahoma/manifest.json new file mode 100644 index 00000000000000..5a1aa460dd0290 --- /dev/null +++ b/homeassistant/components/psoklahoma/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "psoklahoma", + "name": "Public Service Company of Oklahoma (PSO)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/pure_energie/manifest.json b/homeassistant/components/pure_energie/manifest.json index 4c83b5e3651e10..19098c41208fe3 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/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 4ab77fa7893054..09470609c9ef87 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -22,14 +22,14 @@ from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class PureEnergieSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[PureEnergieData], int | float] -@dataclass +@dataclass(frozen=True) class PureEnergieSensorEntityDescription( SensorEntityDescription, PureEnergieSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 6b998f6879e096..f52d0799d356c3 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -7,12 +7,17 @@ from aiopurpleair.models.sensors import SensorModel from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, Platform +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_SHOW_ON_MAP, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_SHOW_ON_MAP, DOMAIN +from .const import DOMAIN from .coordinator import PurpleAirDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 3daa6f96fdfbc6..e2b43726dc47ae 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -14,7 +14,12 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_SHOW_ON_MAP, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import ( @@ -35,7 +40,7 @@ ) from homeassistant.helpers.typing import EventType -from .const import CONF_SENSOR_INDICES, CONF_SHOW_ON_MAP, DOMAIN, LOGGER +from .const import CONF_SENSOR_INDICES, DOMAIN, LOGGER CONF_DISTANCE = "distance" CONF_NEARBY_SENSOR_OPTIONS = "nearby_sensor_options" diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py index e3ea7807a21f00..60f51a9e7ddf9c 100644 --- a/homeassistant/components/purpleair/const.py +++ b/homeassistant/components/purpleair/const.py @@ -7,4 +7,3 @@ CONF_READ_KEY = "read_key" CONF_SENSOR_INDICES = "sensor_indices" -CONF_SHOW_ON_MAP = "show_on_map" diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index fffceffa343ce4..1e78586dece981 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -33,14 +33,14 @@ CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}" -@dataclass +@dataclass(frozen=True) class PurpleAirSensorEntityDescriptionMixin: """Define a description mixin for PurpleAir sensor entities.""" value_fn: Callable[[SensorModel], float | str | None] -@dataclass +@dataclass(frozen=True) class PurpleAirSensorEntityDescription( SensorEntityDescription, PurpleAirSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/pushbullet/api.py b/homeassistant/components/pushbullet/api.py index ff6a57aa9319d6..691ef7413c36d1 100644 --- a/homeassistant/components/pushbullet/api.py +++ b/homeassistant/components/pushbullet/api.py @@ -1,4 +1,5 @@ """Pushbullet Notification provider.""" +from __future__ import annotations from typing import Any @@ -10,7 +11,7 @@ from .const import DATA_UPDATED -class PushBulletNotificationProvider(Listener): +class PushBulletNotificationProvider(Listener): # type: ignore[misc] """Provider for an account, leading to one or more sensors.""" def __init__(self, hass: HomeAssistant, pushbullet: PushBullet) -> None: diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index 1cc851bdb99193..662240d0bf5ef0 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .api import PushBulletNotificationProvider from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -34,8 +35,10 @@ async def async_get_service( """Get the Pushbullet notification service.""" if TYPE_CHECKING: assert discovery_info is not None - pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet - return PushBulletNotificationService(hass, pushbullet) + pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][ + discovery_info["entry_id"] + ] + return PushBulletNotificationService(hass, pb_provider.pushbullet) class PushBulletNotificationService(BaseNotificationService): @@ -120,7 +123,7 @@ def _push_data( pusher: PushBullet, email: str | None = None, phonenumber: str | None = None, - ): + ) -> None: """Create the message content.""" kwargs = {"body": message, "title": title} if email: diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index 9e66d79d2bd9d9..61bd6fd6164269 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.1.0"] + "requirements": ["pvo==2.1.1"] } diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index bcf869d3bbab6c..c003e3cfad8f07 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -28,20 +28,13 @@ from .coordinator import PVOutputDataUpdateCoordinator -@dataclass -class PVOutputSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, 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 808ff1b4cc4a66..00a3a35547736e 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -2,44 +2,34 @@ 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 +import homeassistant.helpers.entity_registry as er 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 +from .helpers import get_enabled_sensor_keys _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) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up pvpc hourly pricing from a config entry.""" - coordinator = ElecPricesDataUpdateCoordinator(hass, entry) + entity_registry = er.async_get(hass) + sensor_keys = get_enabled_sensor_keys( + using_private_api=entry.data.get(CONF_API_TOKEN) is not None, + entries=er.async_entries_for_config_entry(entity_registry, entry.entry_id), + ) + coordinator = ElecPricesDataUpdateCoordinator(hass, entry, sensor_keys) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator @@ -52,7 +42,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( @@ -72,7 +62,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): """Class to manage fetching Electricity prices data from API.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str] + ) -> None: """Initialize.""" self.api = PVPCData( session=async_get_clientsession(hass), @@ -80,6 +72,8 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: 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), + sensor_keys=tuple(sensor_keys), ) super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) @@ -93,11 +87,14 @@ def entry_id(self) -> str: 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 - or not all(api_data.availability.values()) + or not any(api_data.availability.values()) ): raise UpdateFailed return api_data diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index 9412aa2e97d613..66092cb92117d4 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 @@ async def async_step_user( 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, + }, + ) + + 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() - return self.async_show_form(step_id="user", data_schema=UI_CONFIG_SCHEMA) + 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 186ee1171f37cf..a6bfc6f3188bb7 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.const 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/helpers.py b/homeassistant/components/pvpc_hourly_pricing/helpers.py new file mode 100644 index 00000000000000..195d20aee8975e --- /dev/null +++ b/homeassistant/components/pvpc_hourly_pricing/helpers.py @@ -0,0 +1,49 @@ +"""Helper functions to relate sensors keys and unique ids.""" +from aiopvpc.const import ( + ALL_SENSORS, + KEY_INJECTION, + KEY_MAG, + KEY_OMIE, + KEY_PVPC, + TARIFFS, +) + +from homeassistant.helpers.entity_registry import RegistryEntry + +_ha_uniqueid_to_sensor_key = { + TARIFFS[0]: KEY_PVPC, + TARIFFS[1]: KEY_PVPC, + f"{TARIFFS[0]}_{KEY_INJECTION}": KEY_INJECTION, + f"{TARIFFS[1]}_{KEY_INJECTION}": KEY_INJECTION, + f"{TARIFFS[0]}_{KEY_MAG}": KEY_MAG, + f"{TARIFFS[1]}_{KEY_MAG}": KEY_MAG, + f"{TARIFFS[0]}_{KEY_OMIE}": KEY_OMIE, + f"{TARIFFS[1]}_{KEY_OMIE}": KEY_OMIE, +} + + +def get_enabled_sensor_keys( + using_private_api: bool, entries: list[RegistryEntry] +) -> set[str]: + """Get enabled API indicators.""" + if not using_private_api: + return {KEY_PVPC} + if len(entries) > 1: + # activate only enabled sensors + return { + _ha_uniqueid_to_sensor_key[sensor.unique_id] + for sensor in entries + if not sensor.disabled + } + # default sensors when enabling token access + return {KEY_PVPC, KEY_INJECTION} + + +def make_sensor_unique_id(config_entry_id: str | None, sensor_key: str) -> str: + """Generate unique_id for each sensor kind and config entry.""" + assert sensor_key in ALL_SENSORS + assert config_entry_id is not None + if sensor_key == KEY_PVPC: + # for old compatibility + return config_entry_id + return f"{config_entry_id}_{sensor_key}" diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 3368b24b3ff61b..9cc3ef35a4b1a9 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -6,6 +6,8 @@ import logging from typing import Any +from aiopvpc.const import KEY_INJECTION, KEY_MAG, KEY_OMIE, KEY_PVPC + from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, @@ -22,19 +24,49 @@ from . import ElecPricesDataUpdateCoordinator from .const import DOMAIN +from .helpers import make_sensor_unique_id _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key="PVPC", + key=KEY_PVPC, icon="mdi:currency-eur", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, name="PVPC", ), + SensorEntityDescription( + key=KEY_INJECTION, + icon="mdi:transmission-tower-export", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + name="Injection Price", + ), + SensorEntityDescription( + key=KEY_MAG, + icon="mdi:bank-transfer", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + name="MAG tax", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=KEY_OMIE, + icon="mdi:shopping", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + name="OMIE Price", + entity_registry_enabled_default=False, + ), ) _PRICE_SENSOR_ATTRIBUTES_MAP = { + "data_id": "data_id", + "name": "data_name", "tariff": "tariff", "period": "period", "available_power": "available_power", @@ -119,7 +151,11 @@ async def async_setup_entry( ) -> None: """Set up the electricity price sensor from config_entry.""" coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)]) + sensors = [ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)] + if coordinator.api.using_private_api: + for sensor_desc in SENSOR_TYPES[1:]: + sensors.append(ElecPriceSensor(coordinator, sensor_desc, entry.unique_id)) + async_add_entities(sensors) class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], SensorEntity): @@ -137,7 +173,7 @@ def __init__( super().__init__(coordinator) self.entity_description = description self._attr_attribution = coordinator.api.attribution - self._attr_unique_id = unique_id + self._attr_unique_id = make_sensor_unique_id(unique_id, description.key) self._attr_device_info = DeviceInfo( configuration_url="https://api.esios.ree.es", entry_type=DeviceEntryType.SERVICE, @@ -146,9 +182,23 @@ def __init__( name="ESIOS", ) + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data.availability.get( + self.entity_description.key, False + ) + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() + # Enable API downloads for this sensor + self.coordinator.api.update_active_sensors(self.entity_description.key, True) + self.async_on_remove( + lambda: self.coordinator.api.update_active_sensors( + self.entity_description.key, False + ) + ) # Update 'state' value in hour changes self.async_on_remove( @@ -157,10 +207,10 @@ async def async_added_to_hass(self) -> None: ) ) _LOGGER.debug( - "Setup of price sensor %s (%s) with tariff '%s'", - self.name, + "Setup of ESIOS sensor %s (%s, unique_id: %s)", + self.entity_description.key, self.entity_id, - self.coordinator.api.tariff, + self._attr_unique_id, ) @callback diff --git a/homeassistant/components/pvpc_hourly_pricing/strings.json b/homeassistant/components/pvpc_hourly_pricing/strings.json index 1a0055ddbacfc7..4236709fdfaa69 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/__init__.py b/homeassistant/components/python_script/__init__.py index 10751d28c06658..098603b94941aa 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -27,7 +27,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import raise_if_invalid_filename import homeassistant.util.dt as dt_util -from homeassistant.util.yaml.loader import load_yaml +from homeassistant.util.yaml.loader import load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -120,7 +120,7 @@ def python_script_service_handler(call: ServiceCall) -> None: # Load user-provided service descriptions from python_scripts/services.yaml services_yaml = os.path.join(path, "services.yaml") if os.path.exists(services_yaml): - services_dict = load_yaml(services_yaml) + services_dict = load_yaml_dict(services_yaml) else: services_dict = {} diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index bd034053a346d1..dcc0e38c737865 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/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index fd9577f5c73185..84315186097bc2 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -19,21 +19,21 @@ from .coordinator import QBittorrentDataCoordinator from .helpers import setup_client -PLATFORMS = [Platform.SENSOR] - _LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR] + -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up qBittorrent from a config entry.""" - hass.data.setdefault(DOMAIN, {}) + try: client = await hass.async_add_executor_job( setup_client, - entry.data[CONF_URL], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_VERIFY_SSL], + config_entry.data[CONF_URL], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + config_entry.data[CONF_VERIFY_SSL], ) except LoginRequired as err: raise ConfigEntryNotReady("Invalid credentials") from err @@ -42,16 +42,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = QBittorrentDataCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - 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: +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload qBittorrent config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + del hass.data[DOMAIN][config_entry.entry_id] if not hass.data[DOMAIN]: del hass.data[DOMAIN] return unload_ok diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py index 0a79c67f400b8f..96c60e9b38060b 100644 --- a/homeassistant/components/qbittorrent/const.py +++ b/homeassistant/components/qbittorrent/const.py @@ -5,3 +5,7 @@ DEFAULT_NAME = "qBittorrent" DEFAULT_URL = "http://127.0.0.1:8080" + +STATE_UP_DOWN = "up_down" +STATE_SEEDING = "seeding" +STATE_DOWNLOADING = "downloading" diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 8363a764d0a718..11467ce62f48b4 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -18,7 +18,7 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """QBittorrent update coordinator.""" + """Coordinator for updating QBittorrent data.""" def __init__(self, hass: HomeAssistant, client: Client) -> None: """Initialize coordinator.""" diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index e2c1526e4f8a56..fb51f177081beb 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -1,7 +1,7 @@ { "domain": "qbittorrent", "name": "qBittorrent", - "codeowners": ["@geoffreylagaisse"], + "codeowners": ["@geoffreylagaisse", "@finder39"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qbittorrent", "integration_type": "service", diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index e2feee1e60c795..78e8ba59d44eed 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -4,22 +4,21 @@ from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, - SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, UnitOfDataRate 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 -from .const import DOMAIN +from .const import DOMAIN, STATE_DOWNLOADING, STATE_SEEDING, STATE_UP_DOWN from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -27,62 +26,94 @@ SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" +SENSOR_TYPE_ALL_TORRENTS = "all_torrents" +SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents" +SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" +SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents" -@dataclass -class QBittorrentMixin: - """Mixin for required keys.""" - - value_fn: Callable[[dict[str, Any]], StateType] - - -@dataclass -class QBittorrentSensorEntityDescription(SensorEntityDescription, QBittorrentMixin): - """Describes QBittorrent sensor entity.""" - - -def _get_qbittorrent_state(data: dict[str, Any]) -> str: - download = data["server_state"]["dl_info_speed"] - upload = data["server_state"]["up_info_speed"] +def get_state(coordinator: QBittorrentDataCoordinator) -> str: + """Get current download/upload state.""" + upload = coordinator.data["server_state"]["up_info_speed"] + download = coordinator.data["server_state"]["dl_info_speed"] if upload > 0 and download > 0: - return "up_down" + return STATE_UP_DOWN if upload > 0 and download == 0: - return "seeding" + return STATE_SEEDING if upload == 0 and download > 0: - return "downloading" + return STATE_DOWNLOADING return STATE_IDLE -def format_speed(speed): - """Return a bytes/s measurement as a human readable string.""" - kb_spd = float(speed) / 1024 - return round(kb_spd, 2 if kb_spd < 0.1 else 1) +@dataclass(frozen=True, kw_only=True) +class QBittorrentSensorEntityDescription(SensorEntityDescription): + """Entity description class for qBittorent sensors.""" + + value_fn: Callable[[QBittorrentDataCoordinator], StateType] SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( QBittorrentSensorEntityDescription( key=SENSOR_TYPE_CURRENT_STATUS, - name="Status", - value_fn=_get_qbittorrent_state, + translation_key="current_status", + device_class=SensorDeviceClass.ENUM, + options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], + value_fn=get_state, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, - name="Down Speed", + translation_key="download_speed", icon="mdi:cloud-download", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: format_speed(data["server_state"]["dl_info_speed"]), + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=lambda coordinator: float( + coordinator.data["server_state"]["dl_info_speed"] + ), ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, - name="Up Speed", + translation_key="upload_speed", icon="mdi:cloud-upload", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: format_speed(data["server_state"]["up_info_speed"]), + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=lambda coordinator: float( + coordinator.data["server_state"]["up_info_speed"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALL_TORRENTS, + translation_key="all_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states(coordinator, []), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ACTIVE_TORRENTS, + translation_key="active_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["downloading", "uploading"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_INACTIVE_TORRENTS, + translation_key="inactive_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["stalledDL", "stalledUP"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_PAUSED_TORRENTS, + translation_key="paused_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["pausedDL", "pausedUP"] + ), ), ) @@ -90,36 +121,61 @@ def format_speed(speed): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entites: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up qBittorrent sensor entries.""" + coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] - entities = [ - QBittorrentSensor(description, coordinator, config_entry) + + async_add_entities( + QBittorrentSensor(coordinator, config_entry, description) for description in SENSOR_TYPES - ] - async_add_entites(entities) + ) class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEntity): """Representation of a qBittorrent sensor.""" + _attr_has_entity_name = True entity_description: QBittorrentSensorEntityDescription def __init__( self, - description: QBittorrentSensorEntityDescription, coordinator: QBittorrentDataCoordinator, config_entry: ConfigEntry, + entity_description: QBittorrentSensorEntityDescription, ) -> None: """Initialize the qBittorrent sensor.""" super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" - self._attr_name = f"{config_entry.title} {description.name}" - self._attr_available = False + self.entity_description = entity_description + self._attr_unique_id = f"{config_entry.entry_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="QBittorrent", + ) @property def native_value(self) -> StateType: - """Return value of sensor.""" - return self.entity_description.value_fn(self.coordinator.data) + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator) + + +def count_torrents_in_states( + coordinator: QBittorrentDataCoordinator, states: list[str] +) -> int: + """Count the number of torrents in specified states.""" + # When torrents are not in the returned data, there are none, return 0. + if "torrents" not in coordinator.data: + return 0 + + if not states: + return len(coordinator.data["torrents"]) + + return len( + [ + torrent + for torrent in coordinator.data["torrents"].values() + if torrent["state"] in states + ] + ) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 66c9430911e746..8b20a3354ddd53 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -17,5 +17,36 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "download_speed": { + "name": "Download speed" + }, + "upload_speed": { + "name": "Upload speed" + }, + "transmission_status": { + "name": "Status", + "state": { + "idle": "[%key:common::state::idle%]", + "up_down": "Up/Down", + "seeding": "Seeding", + "downloading": "Downloading" + } + }, + "active_torrents": { + "name": "Active torrents" + }, + "inactive_torrents": { + "name": "Inactive torrents" + }, + "paused_torrents": { + "name": "Paused torrents" + }, + "all_torrents": { + "name": "All torrents" + } + } } } diff --git a/homeassistant/components/qingping/manifest.json b/homeassistant/components/qingping/manifest.json index 17593f8c404cba..5cde039c5cee8d 100644 --- a/homeassistant/components/qingping/manifest.json +++ b/homeassistant/components/qingping/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/qingping", "iot_class": "local_push", - "requirements": ["qingping-ble==0.8.2"] + "requirements": ["qingping-ble==0.9.0"] } diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index a5fa3c8a8971ff..d535b9f0e879ed 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/qnap_qsw/binary_sensor.py b/homeassistant/components/qnap_qsw/binary_sensor.py index 5c3fbe13aff581..f655beee3d44d9 100644 --- a/homeassistant/components/qnap_qsw/binary_sensor.py +++ b/homeassistant/components/qnap_qsw/binary_sensor.py @@ -30,7 +30,7 @@ from .entity import QswEntityDescription, QswEntityType, QswSensorEntity -@dataclass +@dataclass(frozen=True) class QswBinarySensorEntityDescription( BinarySensorEntityDescription, QswEntityDescription ): diff --git a/homeassistant/components/qnap_qsw/button.py b/homeassistant/components/qnap_qsw/button.py index acd8d3bd1ef1b6..c2c4f9f604365f 100644 --- a/homeassistant/components/qnap_qsw/button.py +++ b/homeassistant/components/qnap_qsw/button.py @@ -22,14 +22,14 @@ from .entity import QswDataEntity -@dataclass +@dataclass(frozen=True) class QswButtonDescriptionMixin: """Mixin to describe a Button entity.""" press_action: Callable[[QnapQswApi], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class QswButtonDescription(ButtonEntityDescription, QswButtonDescriptionMixin): """Class to describe a Button entity.""" diff --git a/homeassistant/components/qnap_qsw/entity.py b/homeassistant/components/qnap_qsw/entity.py index 4bbfba423e9622..de92afe69a29c5 100644 --- a/homeassistant/components/qnap_qsw/entity.py +++ b/homeassistant/components/qnap_qsw/entity.py @@ -83,13 +83,14 @@ def get_device_value( return value -@dataclass +@dataclass(frozen=True) class QswEntityDescriptionMixin: """Mixin to describe a QSW entity.""" subkey: str +@dataclass(frozen=True) class QswEntityDescription(EntityDescription, QswEntityDescriptionMixin): """Class to describe a QSW entity.""" diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py index 0c287c660732f8..3168e4511d2da1 100644 --- a/homeassistant/components/qnap_qsw/sensor.py +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -50,7 +50,7 @@ from .entity import QswEntityDescription, QswEntityType, QswSensorEntity -@dataclass +@dataclass(frozen=True) class QswSensorEntityDescription(SensorEntityDescription, QswEntityDescription): """A class that describes QNAP QSW sensor entities.""" diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index 8f9c9395adefc4..e47004f5fb7c48 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -7,12 +7,12 @@ from homeassistant.components import cloud from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import CONF_API_KEY, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, CONF_WEBHOOK_ID, DOMAIN +from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, DOMAIN from .device import RachioPerson from .webhooks import ( async_get_or_create_registered_webhook_id_and_url, diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 92a57505a7ccef..dad044e50491c8 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -65,7 +65,6 @@ SIGNAL_RACHIO_ZONE_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_zone" SIGNAL_RACHIO_SCHEDULE_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_schedule" -CONF_WEBHOOK_ID = "webhook_id" CONF_CLOUDHOOK_URL = "cloudhook_url" # Webhook callbacks diff --git a/homeassistant/components/rachio/manifest.json b/homeassistant/components/rachio/manifest.json index e58341633b1b07..1a9d71233c289c 100644 --- a/homeassistant/components/rachio/manifest.json +++ b/homeassistant/components/rachio/manifest.json @@ -2,7 +2,7 @@ "domain": "rachio", "name": "Rachio", "after_dependencies": ["cloud"], - "codeowners": ["@bdraco"], + "codeowners": ["@bdraco", "@rfverbruggen"], "config_flow": true, "dependencies": ["http"], "dhcp": [ @@ -25,7 +25,7 @@ }, "iot_class": "cloud_push", "loggers": ["rachiopy"], - "requirements": ["RachioPy==1.0.3"], + "requirements": ["RachioPy==1.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py index 5c2fbe5965f954..298b9c03701721 100644 --- a/homeassistant/components/rachio/webhooks.py +++ b/homeassistant/components/rachio/webhooks.py @@ -5,13 +5,12 @@ from homeassistant.components import cloud, webhook from homeassistant.config_entries import ConfigEntry -from homeassistant.const import URL_API +from homeassistant.const import CONF_WEBHOOK_ID, URL_API from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( CONF_CLOUDHOOK_URL, - CONF_WEBHOOK_ID, DOMAIN, KEY_EXTERNAL_ID, KEY_TYPE, diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 39258e2f78772d..b6b05b5b568102 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -22,6 +22,7 @@ from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( + CalendarUpdateCoordinator, DiskSpaceDataUpdateCoordinator, HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, @@ -31,7 +32,7 @@ T, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -46,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { + "calendar": CalendarUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py new file mode 100644 index 00000000000000..3a5308fffd5a62 --- /dev/null +++ b/homeassistant/components/radarr/calendar.py @@ -0,0 +1,63 @@ +"""Support for Radarr calendar items.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RadarrEntity +from .const import DOMAIN +from .coordinator import CalendarUpdateCoordinator, RadarrEvent + +CALENDAR_TYPE = EntityDescription( + key="calendar", + name=None, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Radarr calendar entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + async_add_entities([RadarrCalendarEntity(coordinator, CALENDAR_TYPE)]) + + +class RadarrCalendarEntity(RadarrEntity, CalendarEntity): + """A Radarr calendar entity.""" + + coordinator: CalendarUpdateCoordinator + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + if not self.coordinator.event: + return None + return CalendarEvent( + summary=self.coordinator.event.summary, + start=self.coordinator.event.start, + end=self.coordinator.event.end, + description=self.coordinator.event.description, + ) + + # pylint: disable-next=hass-return-type + async def async_get_events( # type: ignore[override] + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get all events in a specific time frame.""" + return await self.coordinator.async_get_events(start_date, end_date) + + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + if self.coordinator.event: + self._attr_extra_state_attributes = { + "release_type": self.coordinator.event.release_type + } + else: + self._attr_extra_state_attributes = {} + super().async_write_ha_state() diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index bd41810bfb8d5c..c14603fe9ca83a 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -2,13 +2,23 @@ from __future__ import annotations from abc import ABC, abstractmethod -from datetime import timedelta +import asyncio +from dataclasses import dataclass +from datetime import date, datetime, timedelta from typing import Generic, TypeVar, cast -from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions +from aiopyarr import ( + Health, + RadarrCalendarItem, + RadarrMovie, + RootFolder, + SystemStatus, + exceptions, +) from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient +from homeassistant.components.calendar import CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -16,13 +26,26 @@ from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER -T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int) +T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int | None) + + +@dataclass +class RadarrEventMixIn: + """Mixin for Radarr calendar event.""" + + release_type: str + + +@dataclass +class RadarrEvent(CalendarEvent, RadarrEventMixIn): + """A class to describe a Radarr calendar event.""" class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): """Data update coordinator for the Radarr integration.""" config_entry: ConfigEntry + update_interval = timedelta(seconds=30) def __init__( self, @@ -35,7 +58,7 @@ def __init__( hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=self.update_interval, ) self.api_client = api_client self.host_configuration = host_configuration @@ -101,3 +124,77 @@ async def _fetch_data(self) -> int: return ( await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) ).totalRecords + + +class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): + """Calendar update coordinator.""" + + update_interval = timedelta(hours=1) + + def __init__( + self, + hass: HomeAssistant, + host_configuration: PyArrHostConfiguration, + api_client: RadarrClient, + ) -> None: + """Initialize.""" + super().__init__(hass, host_configuration, api_client) + self.event: RadarrEvent | None = None + self._events: list[RadarrEvent] = [] + + async def _fetch_data(self) -> None: + """Fetch the calendar.""" + self.event = None + _date = datetime.today() + while self.event is None: + await self.async_get_events(_date, _date + timedelta(days=1)) + for event in self._events: + if event.start >= _date.date(): + self.event = event + break + # Prevent infinite loop in case there is nothing recent in the calendar + if (_date - datetime.today()).days > 45: + break + _date = _date + timedelta(days=1) + + async def async_get_events( + self, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get cached events and request missing dates.""" + # remove older events to prevent memory leak + self._events = [ + e + for e in self._events + if e.start >= datetime.now().date() - timedelta(days=30) + ] + _days = (end_date - start_date).days + await asyncio.gather( + *( + self._async_get_events(d) + for d in ((start_date + timedelta(days=x)).date() for x in range(_days)) + if d not in (event.start for event in self._events) + ) + ) + return self._events + + async def _async_get_events(self, _date: date) -> None: + """Return events from specified date.""" + self._events.extend( + _get_calendar_event(evt) + for evt in await self.api_client.async_get_calendar( + start_date=_date, end_date=_date + timedelta(days=1) + ) + if evt.title not in (e.summary for e in self._events) + ) + + +def _get_calendar_event(event: RadarrCalendarItem) -> RadarrEvent: + """Return a RadarrEvent from an API event.""" + _date, _type = event.releaseDateType() + return RadarrEvent( + summary=event.title, + start=_date - timedelta(days=1), + end=_date, + description=event.overview.replace(":", ";"), + release_type=_type, + ) diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index ab4315b269aa7a..ad9dd4e1ae0dc3 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from copy import deepcopy -from dataclasses import dataclass +import dataclasses from datetime import UTC, datetime from typing import Any, Generic @@ -39,21 +38,23 @@ def get_modified_description( description: RadarrSensorEntityDescription[T], mount: RootFolder ) -> tuple[RadarrSensorEntityDescription[T], str]: """Return modified description and folder name.""" - desc = deepcopy(description) name = mount.path.rsplit("/")[-1].rsplit("\\")[-1] - desc.key = f"{description.key}_{name}" - desc.name = f"{description.name} {name}".capitalize() + desc = dataclasses.replace( + description, + key=f"{description.key}_{name}", + name=f"{description.name} {name}".capitalize(), + ) return desc, name -@dataclass +@dataclasses.dataclass(frozen=True) class RadarrSensorEntityDescriptionMixIn(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T, str], str | int | datetime] -@dataclass +@dataclasses.dataclass(frozen=True) class RadarrSensorEntityDescription( SensorEntityDescription, RadarrSensorEntityDescriptionMixIn[T], Generic[T] ): diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index 693811f59abd90..e76bd2d3f2dce1 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 e7a7c1200b96af..e5731dc08fe01b 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -10,10 +10,9 @@ 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/manifest.json b/homeassistant/components/rainbird/manifest.json index 07a0bc0a5f690e..b8cb86264f236f 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rainbird", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==4.0.0"] + "requirements": ["pyrainbird==4.0.1"] } diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 6046189ddc485e..ea0d64f6208946 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 58c7f6bd795a37..7b5054bfb0f9c8 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/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index c29154a941c467..5c3ff18f71c549 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta from functools import partial, wraps @@ -38,6 +38,7 @@ from .config_flow import get_client_controller from .const import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_DURATION, CONF_USE_APP_RUN_TIMES, @@ -48,6 +49,7 @@ DATA_RESTRICTIONS_CURRENT, DATA_RESTRICTIONS_UNIVERSAL, DATA_ZONES, + DEFAULT_ZONE_RUN, DOMAIN, LOGGER, ) @@ -249,8 +251,13 @@ async def async_setup_entry( # noqa: C901 **entry.options, CONF_DEFAULT_ZONE_RUN_TIME: data.pop(CONF_DEFAULT_ZONE_RUN_TIME), } + entry_updates["options"] = {**entry.options} if CONF_USE_APP_RUN_TIMES not in entry.options: - entry_updates["options"] = {**entry.options, CONF_USE_APP_RUN_TIMES: False} + entry_updates["options"][CONF_USE_APP_RUN_TIMES] = False + if CONF_DEFAULT_ZONE_RUN_TIME not in entry.options: + entry_updates["options"][CONF_DEFAULT_ZONE_RUN_TIME] = DEFAULT_ZONE_RUN + if CONF_ALLOW_INACTIVE_ZONES_TO_RUN not in entry.options: + entry_updates["options"][CONF_ALLOW_INACTIVE_ZONES_TO_RUN] = False if entry_updates: hass.config_entries.async_update_entry(entry, **entry_updates) @@ -326,10 +333,17 @@ async def async_init_coordinator( entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - def call_with_controller(update_programs_and_zones: bool = True) -> Callable: + def call_with_controller( + update_programs_and_zones: bool = True, + ) -> Callable[ + [Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]]], + Callable[[ServiceCall], Coroutine[Any, Any, None]], + ]: """Hydrate a service call with the appropriate controller.""" - def decorator(func: Callable) -> Callable[..., Awaitable]: + def decorator( + func: Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]], + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define the decorator.""" @wraps(func) diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 7f93db67c4cb32..f0cbfd636fa32e 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -32,7 +32,7 @@ TYPE_WEEKDAY = "weekday" -@dataclass +@dataclass(frozen=True) class RainMachineBinarySensorDescription( BinarySensorEntityDescription, RainMachineEntityDescription, diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index 82829094957048..a13d2069007248 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -24,14 +24,14 @@ from .model import RainMachineEntityDescription -@dataclass +@dataclass(frozen=True) class RainMachineButtonDescriptionMixin: """Define an entity description mixin for RainMachine buttons.""" push_action: Callable[[Controller], Awaitable] -@dataclass +@dataclass(frozen=True) class RainMachineButtonDescription( ButtonEntityDescription, RainMachineEntityDescription, diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 1ad97de7d0b42c..1d73ef3dd881f1 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_USE_APP_RUN_TIMES, DEFAULT_PORT, @@ -188,6 +189,12 @@ async def async_step_init( CONF_USE_APP_RUN_TIMES, default=self.config_entry.options.get(CONF_USE_APP_RUN_TIMES), ): bool, + vol.Optional( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, + default=self.config_entry.options.get( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN + ), + ): bool, } ), ) diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py index 00af0bd0b75b4e..e28b2326b790f1 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -8,6 +8,7 @@ CONF_DURATION = "duration" CONF_DEFAULT_ZONE_RUN_TIME = "zone_run_time" CONF_USE_APP_RUN_TIMES = "use_app_run_times" +CONF_ALLOW_INACTIVE_ZONES_TO_RUN = "allow_inactive_zones_to_run" DATA_API_VERSIONS = "api.versions" DATA_MACHINE_FIRMWARE_UPDATE_STATUS = "machine.firmware_update_status" diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py index 9ae99fe247ad95..e45448c0fe4a78 100644 --- a/homeassistant/components/rainmachine/model.py +++ b/homeassistant/components/rainmachine/model.py @@ -4,28 +4,28 @@ from homeassistant.helpers.entity import EntityDescription -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescriptionMixinApiCategory: """Define an entity description mixin to include an API category.""" api_category: str -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescriptionMixinDataKey: """Define an entity description mixin to include a data payload key.""" data_key: str -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescriptionMixinUid: """Define an entity description mixin to include an activity UID.""" uid: int -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescription( EntityDescription, RainMachineEntityDescriptionMixinApiCategory ): diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index 2a5bc93f60146f..513c02ddc19a75 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -22,7 +22,7 @@ from .util import key_exists -@dataclass +@dataclass(frozen=True) class RainMachineSelectDescription( SelectEntityDescription, RainMachineEntityDescription, @@ -40,14 +40,14 @@ class FreezeProtectionSelectOption: metric_label: str -@dataclass +@dataclass(frozen=True) class FreezeProtectionTemperatureMixin: """Define an entity description mixin to include an options list.""" extended_options: list[FreezeProtectionSelectOption] -@dataclass +@dataclass(frozen=True) class FreezeProtectionSelectDescription( RainMachineSelectDescription, FreezeProtectionTemperatureMixin ): diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index bdae62c1bd8d30..624deeb46c6e06 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -48,7 +48,7 @@ TYPE_ZONE_RUN_COMPLETION_TIME = "zone_run_completion_time" -@dataclass +@dataclass(frozen=True) class RainMachineSensorDataDescription( SensorEntityDescription, RainMachineEntityDescription, @@ -57,7 +57,7 @@ class RainMachineSensorDataDescription( """Describe a RainMachine sensor.""" -@dataclass +@dataclass(frozen=True) class RainMachineSensorCompletionTimerDescription( SensorEntityDescription, RainMachineEntityDescription, diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index ac2b86754e5de1..a564d33e777d51 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -24,7 +24,8 @@ "title": "Configure RainMachine", "data": { "zone_run_time": "Default zone run time (in seconds)", - "use_app_run_times": "Use zone run times from RainMachine app" + "use_app_run_times": "Use zone run times from the RainMachine app", + "allow_inactive_zones_to_run": "Allow disabled zones to be run manually" } } } diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index e6ed92d04dc25b..b47396bc9e522f 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -20,6 +20,7 @@ from . import RainMachineData, RainMachineEntity, async_update_programs_and_zones from .const import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_DURATION, CONF_USE_APP_RUN_TIMES, @@ -117,7 +118,7 @@ def raise_on_request_error( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Define a decorator to raise on a request error.""" @@ -133,7 +134,7 @@ async def decorator(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: return decorator -@dataclass +@dataclass(frozen=True) class RainMachineSwitchDescription( SwitchEntityDescription, RainMachineEntityDescription, @@ -141,14 +142,14 @@ class RainMachineSwitchDescription( """Describe a RainMachine switch.""" -@dataclass +@dataclass(frozen=True) class RainMachineActivitySwitchDescription( RainMachineSwitchDescription, RainMachineEntityDescriptionMixinUid ): """Describe a RainMachine activity (program/zone) switch.""" -@dataclass +@dataclass(frozen=True) class RainMachineRestrictionSwitchDescription( RainMachineSwitchDescription, RainMachineEntityDescriptionMixinDataKey ): @@ -300,7 +301,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: The only way this could occur is if someone rapidly turns a disabled activity off right after turning it on. """ - if not self.coordinator.data[self.entity_description.uid]["active"]: + if ( + not self._entry.options[CONF_ALLOW_INACTIVE_ZONES_TO_RUN] + and not self.coordinator.data[self.entity_description.uid]["active"] + ): raise HomeAssistantError( f"Cannot turn off an inactive program/zone: {self.name}" ) @@ -314,7 +318,10 @@ async def async_turn_off_when_active(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - if not self.coordinator.data[self.entity_description.uid]["active"]: + if ( + not self._entry.options[CONF_ALLOW_INACTIVE_ZONES_TO_RUN] + and not self.coordinator.data[self.entity_description.uid]["active"] + ): self._attr_is_on = False self.async_write_ha_state() raise HomeAssistantError( diff --git a/homeassistant/components/raspberry_pi/hardware.py b/homeassistant/components/raspberry_pi/hardware.py index e90316ccb3c145..2141ff6034d7d1 100644 --- a/homeassistant/components/raspberry_pi/hardware.py +++ b/homeassistant/components/raspberry_pi/hardware.py @@ -17,6 +17,7 @@ "rpi3-64": "Raspberry Pi 3", "rpi4": "Raspberry Pi 4 (32-bit)", "rpi4-64": "Raspberry Pi 4", + "rpi5-64": "Raspberry Pi 5", } MODELS = { @@ -28,6 +29,7 @@ "rpi3-64": "3", "rpi4": "4", "rpi4-64": "4", + "rpi5-64": "5", } diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 16a93485b36f53..ce8e2908251fcd 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -23,20 +23,13 @@ from .const import DOMAIN -@dataclass -class RDWBinarySensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, 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/manifest.json b/homeassistant/components/rdw/manifest.json index e63478976e372f..f44dc7e0f127a3 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.2.0"] + "requirements": ["vehicle==2.2.1"] } diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index f330ac16b8ef00..a6ad9047852f94 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -24,20 +24,13 @@ from .const import CONF_LICENSE_PLATE, DOMAIN -@dataclass -class RDWSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, 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 aa036f33999b4b..aedf917dd228b0 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 06c8cf68903ba2..b864e104ae61f8 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -176,13 +176,17 @@ def result_processor(self, dialect, coltype): # type: ignore[no-untyped-def] # 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 bf76c7264d545a..fda8716df27837 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -244,7 +244,8 @@ def events_entity_filter(self) -> ColumnElement: ), # 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 68c357c0ed4375..da58822e266d74 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"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < epoch_time) + # 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.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 b630a71daffaa3..13ba7400952a98 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.23", + "SQLAlchemy==2.0.25", "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 8808ed2fd2bddf..427e3acab2d7cd 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 8bc6584c5a1b97..8dd539f84f3679 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -41,7 +41,7 @@ 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 78c475753a2a11..023f94ec88e027 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -11,7 +11,6 @@ import logging from operator import itemgetter import re -from statistics import mean from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text @@ -115,7 +114,7 @@ StatisticsShortTerm.state, StatisticsShortTerm.sum, func.row_number() - .over( # type: ignore[no-untyped-call] + .over( partition_by=StatisticsShortTerm.metadata_id, order_by=StatisticsShortTerm.start_ts.desc(), ) @@ -145,6 +144,17 @@ DATA_SHORT_TERM_STATISTICS_RUN_CACHE = "recorder_short_term_statistics_run_cache" +def mean(values: list[float]) -> float | None: + """Return the mean of the values. + + This is a very simple version that only works + with a non-empty list of floats. The built-in + statistics.mean is more robust but is is almost + an order of magnitude slower. + """ + return sum(values) / len(values) + + _LOGGER = logging.getLogger(__name__) @@ -782,7 +792,7 @@ def _statistic_by_id_from_metadata( def _flatten_list_statistic_ids_metadata_result( - result: dict[str, dict[str, Any]] + result: dict[str, dict[str, Any]], ) -> list[dict]: """Return a flat dict of metadata.""" return [ diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py index e56ee4f3415e06..9a0945dc4d95f8 100644 --- a/homeassistant/components/recorder/table_managers/__init__.py +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -1,9 +1,8 @@ """Managers for each table.""" -from collections.abc import MutableMapping from typing import TYPE_CHECKING, Generic, TypeVar -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU if TYPE_CHECKING: from ..core import Recorder @@ -14,6 +13,8 @@ class BaseTableManager(Generic[_DataT]): """Base class for table managers.""" + _id_map: "LRU[str, int]" + def __init__(self, recorder: "Recorder") -> None: """Initialize the table manager. @@ -24,7 +25,6 @@ def __init__(self, recorder: "Recorder") -> None: self.active = False self.recorder = recorder self._pending: dict[str, _DataT] = {} - self._id_map: MutableMapping[str, int] = {} def get_from_cache(self, data: str) -> int | None: """Resolve data to the id without accessing the underlying database. @@ -62,7 +62,7 @@ def __init__(self, recorder: "Recorder", lru_size: int) -> None: and evict the least recently used items when the cache is full. """ super().__init__(recorder) - self._id_map: MutableMapping[str, int] = LRU(lru_size) + self._id_map = LRU(lru_size) def adjust_lru_size(self, new_size: int) -> None: """Adjust the LRU cache size. @@ -70,6 +70,6 @@ def adjust_lru_size(self, new_size: int) -> None: This call is not thread-safe and must be called from the recorder thread. """ - lru: LRU = self._id_map + lru = self._id_map if new_size > lru.get_size(): lru.set_size(new_size) diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 45b3b96353c601..c74684a0f77529 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -4,7 +4,7 @@ from collections.abc import Iterable from typing import TYPE_CHECKING, cast -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from sqlalchemy.orm.session import Session from homeassistant.core import Event @@ -28,7 +28,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) - self._non_existent_event_types: LRU = LRU(CACHE_SIZE) + self._non_existent_event_types: LRU[str, None] = LRU(CACHE_SIZE) def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory. diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index a484bdf145e744..76def3a22fe5d8 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -5,7 +5,7 @@ import threading from typing import TYPE_CHECKING, Literal, cast -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from sqlalchemy import lambda_stmt, select from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import true @@ -74,7 +74,7 @@ class StatisticsMetaManager: def __init__(self, recorder: Recorder) -> None: """Initialize the statistics meta manager.""" self.recorder = recorder - self._stat_id_to_id_meta: dict[str, tuple[int, StatisticMetaData]] = LRU( + self._stat_id_to_id_meta: LRU[str, tuple[int, StatisticMetaData]] = LRU( CACHE_SIZE ) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index f94601bb2cbb96..4a1bf940b244ad 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 @@ -658,7 +658,7 @@ def database_job_retry_wrapper( """ def decorator( - job: _WrappedFuncType[_RecorderT, _P] + job: _WrappedFuncType[_RecorderT, _P], ) -> _WrappedFuncType[_RecorderT, _P]: @functools.wraps(job) def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> None: @@ -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/refoss/__init__.py b/homeassistant/components/refoss/__init__.py new file mode 100644 index 00000000000000..d83ca17dd6ba40 --- /dev/null +++ b/homeassistant/components/refoss/__init__.py @@ -0,0 +1,56 @@ +"""Refoss devices platform loader.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_time_interval + +from .bridge import DiscoveryService +from .const import COORDINATORS, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DOMAIN +from .util import refoss_discovery_server + +PLATFORMS: Final = [ + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Refoss from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + discover = await refoss_discovery_server(hass) + refoss_discovery = DiscoveryService(hass, discover) + hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] = refoss_discovery + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_scan_update(_=None): + await refoss_discovery.discovery.broadcast_msg() + + await _async_scan_update() + + entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + ) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if hass.data[DOMAIN].get(DATA_DISCOVERY_SERVICE) is not None: + refoss_discovery: DiscoveryService = hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] + refoss_discovery.discovery.clean_up() + hass.data[DOMAIN].pop(DATA_DISCOVERY_SERVICE) + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(COORDINATORS) + + return unload_ok diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py new file mode 100644 index 00000000000000..888179e8a7cb33 --- /dev/null +++ b/homeassistant/components/refoss/bridge.py @@ -0,0 +1,45 @@ +"""Refoss integration.""" +from __future__ import annotations + +from refoss_ha.device import DeviceInfo +from refoss_ha.device_manager import async_build_base_device +from refoss_ha.discovery import Discovery, Listener + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .coordinator import RefossDataUpdateCoordinator + + +class DiscoveryService(Listener): + """Discovery event handler for refoss devices.""" + + def __init__(self, hass: HomeAssistant, discovery: Discovery) -> None: + """Init discovery service.""" + self.hass = hass + + self.discovery = discovery + self.discovery.add_listener(self) + + hass.data[DOMAIN].setdefault(COORDINATORS, []) + + async def device_found(self, device_info: DeviceInfo) -> None: + """Handle new device found on the network.""" + + device = await async_build_base_device(device_info) + if device is None: + return None + + coordo = RefossDataUpdateCoordinator(self.hass, device) + self.hass.data[DOMAIN][COORDINATORS].append(coordo) + await coordo.async_refresh() + + async_dispatcher_send(self.hass, DISPATCH_DEVICE_DISCOVERED, coordo) + + async def device_update(self, device_info: DeviceInfo) -> None: + """Handle updates in device information, update if ip has changed.""" + for coordinator in self.hass.data[DOMAIN][COORDINATORS]: + if coordinator.device.device_info.mac == device_info.mac: + coordinator.device.device_info.inner_ip = device_info.inner_ip + await coordinator.async_refresh() diff --git a/homeassistant/components/refoss/config_flow.py b/homeassistant/components/refoss/config_flow.py new file mode 100644 index 00000000000000..fe33cefc1bd8c9 --- /dev/null +++ b/homeassistant/components/refoss/config_flow.py @@ -0,0 +1,20 @@ +"""Config Flow for Refoss integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_flow + +from .const import DISCOVERY_TIMEOUT, DOMAIN +from .util import refoss_discovery_server + + +async def _async_has_devices(hass: HomeAssistant) -> bool: + """Return if there are devices that can be discovered.""" + + refoss_discovery = await refoss_discovery_server(hass) + devices = await refoss_discovery.broadcast_msg(wait_for=DISCOVERY_TIMEOUT) + return len(devices) > 0 + + +config_entry_flow.register_discovery_flow(DOMAIN, "Refoss", _async_has_devices) diff --git a/homeassistant/components/refoss/const.py b/homeassistant/components/refoss/const.py new file mode 100644 index 00000000000000..dd11921c75edd5 --- /dev/null +++ b/homeassistant/components/refoss/const.py @@ -0,0 +1,20 @@ +"""const.""" +from __future__ import annotations + +from logging import Logger, getLogger + +_LOGGER: Logger = getLogger(__package__) + +COORDINATORS = "coordinators" + +DATA_DISCOVERY_SERVICE = "refoss_discovery" + +DISCOVERY_SCAN_INTERVAL = 30 +DISCOVERY_TIMEOUT = 8 +DISPATCH_DEVICE_DISCOVERED = "refoss_device_discovered" +DISPATCHERS = "dispatchers" + +DOMAIN = "refoss" +COORDINATOR = "coordinator" + +MAX_ERRORS = 2 diff --git a/homeassistant/components/refoss/coordinator.py b/homeassistant/components/refoss/coordinator.py new file mode 100644 index 00000000000000..a542f0e1ae8a71 --- /dev/null +++ b/homeassistant/components/refoss/coordinator.py @@ -0,0 +1,39 @@ +"""Helper and coordinator for refoss.""" +from __future__ import annotations + +from datetime import timedelta + +from refoss_ha.controller.device import BaseDevice +from refoss_ha.exceptions import DeviceTimeoutError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import _LOGGER, DOMAIN, MAX_ERRORS + + +class RefossDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Manages polling for state changes from the device.""" + + def __init__(self, hass: HomeAssistant, device: BaseDevice) -> None: + """Initialize the data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{device.device_info.dev_name}", + update_interval=timedelta(seconds=15), + ) + self.device = device + self._error_count = 0 + + async def _async_update_data(self) -> None: + """Update the state of the device.""" + try: + await self.device.async_handle_update() + self.last_update_success = True + self._error_count = 0 + except DeviceTimeoutError: + self._error_count += 1 + + if self._error_count >= MAX_ERRORS: + self.last_update_success = False diff --git a/homeassistant/components/refoss/entity.py b/homeassistant/components/refoss/entity.py new file mode 100644 index 00000000000000..d3425974bb157c --- /dev/null +++ b/homeassistant/components/refoss/entity.py @@ -0,0 +1,31 @@ +"""Entity object for shared properties of Refoss entities.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .bridge import RefossDataUpdateCoordinator +from .const import DOMAIN + + +class RefossEntity(CoordinatorEntity[RefossDataUpdateCoordinator]): + """Refoss entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: RefossDataUpdateCoordinator, channel: int) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + mac = coordinator.device.mac + self.channel_id = channel + if channel == 0: + self._attr_name = None + else: + self._attr_name = str(channel) + + self._attr_unique_id = f"{mac}_{channel}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, mac)}, + identifiers={(DOMAIN, mac)}, + manufacturer="Refoss", + name=coordinator.device.dev_name, + ) diff --git a/homeassistant/components/refoss/manifest.json b/homeassistant/components/refoss/manifest.json new file mode 100644 index 00000000000000..8e5b3864bcc9e4 --- /dev/null +++ b/homeassistant/components/refoss/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "refoss", + "name": "Refoss", + "codeowners": ["@ashionky"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/refoss", + "iot_class": "local_polling", + "requirements": ["refoss-ha==1.2.0"] +} diff --git a/homeassistant/components/refoss/strings.json b/homeassistant/components/refoss/strings.json new file mode 100644 index 00000000000000..ad8f0f41ae7b29 --- /dev/null +++ b/homeassistant/components/refoss/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py new file mode 100644 index 00000000000000..c51f166059e221 --- /dev/null +++ b/homeassistant/components/refoss/switch.py @@ -0,0 +1,69 @@ +"""Switch for Refoss.""" + +from __future__ import annotations + +from typing import Any + +from refoss_ha.controller.toggle import ToggleXMix + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .entity import RefossEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Refoss device from a config entry.""" + + @callback + def init_device(coordinator): + """Register the device.""" + device = coordinator.device + if not isinstance(device, ToggleXMix): + return + + new_entities = [] + for channel in device.channels: + entity = RefossSwitch(coordinator=coordinator, channel=channel) + new_entities.append(entity) + + async_add_entities(new_entities) + + for coordinator in hass.data[DOMAIN][COORDINATORS]: + init_device(coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) + ) + + +class RefossSwitch(RefossEntity, SwitchEntity): + """Refoss Switch Device.""" + + @property + def is_on(self) -> bool | None: + """Return true if switch is on.""" + return self.coordinator.device.is_on(channel=self.channel_id) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.device.async_turn_on(self.channel_id) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.device.async_turn_off(self.channel_id) + self.async_write_ha_state() + + async def async_toggle(self, **kwargs: Any) -> None: + """Toggle the switch.""" + await self.coordinator.device.async_toggle(channel=self.channel_id) + self.async_write_ha_state() diff --git a/homeassistant/components/refoss/util.py b/homeassistant/components/refoss/util.py new file mode 100644 index 00000000000000..cd589022d73ba6 --- /dev/null +++ b/homeassistant/components/refoss/util.py @@ -0,0 +1,15 @@ +"""Refoss helpers functions.""" +from __future__ import annotations + +from refoss_ha.discovery import Discovery + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import singleton + + +@singleton.singleton("refoss_discovery_server") +async def refoss_discovery_server(hass: HomeAssistant) -> Discovery: + """Get refoss Discovery server.""" + discovery_server = Discovery() + await discovery_server.initialize() + return discovery_server diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 17915e1be195d7..7e9ebfe12b9f5e 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -26,11 +25,22 @@ PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) ATTR_ACTIVITY = "activity" @@ -71,9 +81,20 @@ class RemoteEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the RemoteEntityFeature enum instead. -SUPPORT_LEARN_COMMAND = 1 -SUPPORT_DELETE_COMMAND = 2 -SUPPORT_ACTIVITY = 4 +_DEPRECATED_SUPPORT_LEARN_COMMAND = DeprecatedConstantEnum( + RemoteEntityFeature.LEARN_COMMAND, "2025.1" +) +_DEPRECATED_SUPPORT_DELETE_COMMAND = DeprecatedConstantEnum( + RemoteEntityFeature.DELETE_COMMAND, "2025.1" +) +_DEPRECATED_SUPPORT_ACTIVITY = DeprecatedConstantEnum( + RemoteEntityFeature.ACTIVITY, "2025.1" +) + + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) REMOTE_SERVICE_ACTIVITY_SCHEMA = make_entity_service_schema( {vol.Optional(ATTR_ACTIVITY): cv.string} @@ -155,12 +176,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class RemoteEntityDescription(ToggleEntityDescription): +class RemoteEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes remote entities.""" -class RemoteEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "supported_features", + "current_activity", + "activity_list", +} + + +class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for remote entities.""" entity_description: RemoteEntityDescription @@ -168,17 +195,30 @@ class RemoteEntity(ToggleEntity): _attr_current_activity: str | None = None _attr_supported_features: RemoteEntityFeature = RemoteEntityFeature(0) - @property + @cached_property def supported_features(self) -> RemoteEntityFeature: """Flag supported features.""" return self._attr_supported_features @property + def supported_features_compat(self) -> RemoteEntityFeature: + """Return the supported features as RemoteEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = RemoteEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + + @cached_property def current_activity(self) -> str | None: """Active activity.""" return self._attr_current_activity - @property + @cached_property def activity_list(self) -> list[str] | None: """List of available activities.""" return self._attr_activity_list @@ -187,7 +227,7 @@ def activity_list(self) -> list[str] | None: @property def state_attributes(self) -> dict[str, Any] | None: """Return optional state attributes.""" - if not self.supported_features & RemoteEntityFeature.ACTIVITY: + if RemoteEntityFeature.ACTIVITY not in self.supported_features_compat: return None return { diff --git a/homeassistant/components/remote/significant_change.py b/homeassistant/components/remote/significant_change.py new file mode 100644 index 00000000000000..8e5a36690411da --- /dev/null +++ b/homeassistant/components/remote/significant_change.py @@ -0,0 +1,27 @@ +"""Helper to test significant Remote state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_CURRENT_ACTIVITY + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + if old_attrs.get(ATTR_CURRENT_ACTIVITY) != new_attrs.get(ATTR_CURRENT_ACTIVITY): + return True + + return False diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index f69451290bcf6d..6b5679088a0327 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {}) try: await renault_hub.async_initialise(config_entry) - except aiohttp.ClientResponseError as exc: + except aiohttp.ClientError as exc: raise ConfigEntryNotReady() from exc hass.data[DOMAIN][config_entry.entry_id] = renault_hub diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index ef2d7196f04852..0d66e5444e7c42 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -22,7 +22,7 @@ from .renault_hub import RenaultHub -@dataclass +@dataclass(frozen=True) class RenaultBinarySensorRequiredKeysMixin: """Mixin for required keys.""" @@ -30,7 +30,7 @@ class RenaultBinarySensorRequiredKeysMixin: on_value: StateType -@dataclass +@dataclass(frozen=True) class RenaultBinarySensorEntityDescription( BinarySensorEntityDescription, RenaultDataEntityDescription, diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index 5f916a2d14045b..878832048901a3 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -15,14 +15,14 @@ from .renault_hub import RenaultHub -@dataclass +@dataclass(frozen=True) class RenaultButtonRequiredKeysMixin: """Mixin for required keys.""" async_press: Callable[[RenaultButtonEntity], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class RenaultButtonEntityDescription( ButtonEntityDescription, RenaultButtonRequiredKeysMixin ): diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index d101b551dfe44a..f8e6a21823ab8b 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -45,6 +45,7 @@ def __init__( ) 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 @@ async def _async_update_data(self) -> 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/renault/entity.py b/homeassistant/components/renault/entity.py index aa83c935957c94..fd7f0eb3654b8c 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -12,14 +12,14 @@ from .renault_vehicle import RenaultVehicleProxy -@dataclass +@dataclass(frozen=True) class RenaultDataRequiredKeysMixin: """Mixin for required keys.""" coordinator: str -@dataclass +@dataclass(frozen=True) class RenaultDataEntityDescription(EntityDescription, RenaultDataRequiredKeysMixin): """Class describing Renault data entities.""" diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index e5470259aa42ac..98e1c8b1e7c811 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "platinum", - "requirements": ["renault-api==0.2.0"] + "requirements": ["renault-api==0.2.1"] } diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 6dd0dc2611eb77..e44a50d57a1af9 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -26,7 +26,7 @@ def with_error_wrapping( - func: Callable[Concatenate[RenaultVehicleProxy, _P], Awaitable[_T]] + func: Callable[Concatenate[RenaultVehicleProxy, _P], Awaitable[_T]], ) -> Callable[Concatenate[RenaultVehicleProxy, _P], Coroutine[Any, Any, _T]]: """Catch Renault errors.""" diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index 1ec891a51e4857..9dcc52abc87fb9 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -18,7 +18,7 @@ from .renault_hub import RenaultHub -@dataclass +@dataclass(frozen=True) class RenaultSelectRequiredKeysMixin: """Mixin for required keys.""" @@ -26,7 +26,7 @@ class RenaultSelectRequiredKeysMixin: icon_lambda: Callable[[RenaultSelectEntity], str] -@dataclass +@dataclass(frozen=True) class RenaultSelectEntityDescription( SelectEntityDescription, RenaultDataEntityDescription, diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 92deb3438defe9..d30b8d01fb3bcb 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -43,7 +43,7 @@ from .renault_vehicle import RenaultVehicleProxy -@dataclass +@dataclass(frozen=True) class RenaultSensorRequiredKeysMixin(Generic[T]): """Mixin for required keys.""" @@ -51,7 +51,7 @@ class RenaultSensorRequiredKeysMixin(Generic[T]): entity_class: type[RenaultSensor[T]] -@dataclass +@dataclass(frozen=True) class RenaultSensorEntityDescription( SensorEntityDescription, RenaultDataEntityDescription, diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index d25b73cafc27c7..d2c7d45184479d 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -43,13 +43,15 @@ { vol.Required("id"): cv.positive_int, vol.Optional("activated"): cv.boolean, - vol.Optional("monday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("tuesday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("wednesday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("thursday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("friday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("saturday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), - vol.Optional("sunday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("monday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("tuesday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("wednesday"): vol.Any( + None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA + ), + vol.Optional("thursday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("friday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("saturday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("sunday"): vol.Any(None, SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), } ) SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py index 39c2b1b883d342..012ecee2e9876b 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -30,14 +30,14 @@ from .entity import RensonEntity -@dataclass +@dataclass(frozen=True) class RensonBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" field: FieldEnum -@dataclass +@dataclass(frozen=True) class RensonBinarySensorEntityDescription( BinarySensorEntityDescription, RensonBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index a91a057e0e7b93..117fadb502ba39 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -21,14 +21,14 @@ from .entity import RensonEntity -@dataclass +@dataclass(frozen=True) class RensonButtonEntityDescriptionMixin: """Action function called on press.""" action_fn: Callable[[RensonVentilation], None] -@dataclass +@dataclass(frozen=True) class RensonButtonEntityDescription( ButtonEntityDescription, RensonButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/renson/const.py b/homeassistant/components/renson/const.py index 840e1ce428ae8f..53bbd90c4b794a 100644 --- a/homeassistant/components/renson/const.py +++ b/homeassistant/components/renson/const.py @@ -1,3 +1,4 @@ """Constants for the Renson integration.""" + DOMAIN = "renson" diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index da6850859a6696..a60adccade57b5 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -7,16 +7,19 @@ from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType from renson_endura_delta.renson import Level, RensonVentilation +import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN from .coordinator import RensonCoordinator @@ -41,6 +44,33 @@ Level.LEVEL4.value: 4, } +SET_TIMER_LEVEL_SCHEMA = { + vol.Required("timer_level"): vol.In( + ["level1", "level2", "level3", "level4", "holiday", "breeze"] + ), + vol.Required("minutes"): cv.positive_int, +} + +SET_BREEZE_SCHEMA = { + vol.Required("breeze_level"): vol.In(["level1", "level2", "level3", "level4"]), + vol.Required("temperature"): cv.positive_int, + vol.Required("activate"): bool, +} + +SET_POLLUTION_SETTINGS_SCHEMA = { + vol.Required("day_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Required("night_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Optional("humidity_control", default=True): bool, + vol.Optional("airquality_control", default=True): bool, + vol.Optional("co2_control", default=True): bool, + vol.Optional("co2_threshold", default=600): cv.positive_int, + vol.Optional("co2_hysteresis", default=100): cv.positive_int, +} + SPEED_RANGE: tuple[float, float] = (1, 4) @@ -59,6 +89,24 @@ async def async_setup_entry( async_add_entities([RensonFan(api, coordinator)]) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + "set_timer_level", + SET_TIMER_LEVEL_SCHEMA, + "set_timer_level", + ) + + platform.async_register_entity_service( + "set_breeze", SET_BREEZE_SCHEMA, "set_breeze" + ) + + platform.async_register_entity_service( + "set_pollution_settings", + SET_POLLUTION_SETTINGS_SCHEMA, + "set_pollution_settings", + ) + class RensonFan(RensonEntity, FanEntity): """Representation of the Renson fan platform.""" @@ -116,3 +164,43 @@ async def async_set_percentage(self, percentage: int) -> None: await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) await self.coordinator.async_request_refresh() + + async def set_timer_level(self, timer_level: str, minutes: int) -> None: + """Set timer level.""" + level = Level[str(timer_level).upper()] + + await self.hass.async_add_executor_job(self.api.set_timer_level, level, minutes) + + async def set_breeze( + self, breeze_level: str, temperature: int, activate: bool + ) -> None: + """Configure breeze feature.""" + level = Level[str(breeze_level).upper()] + + await self.hass.async_add_executor_job( + self.api.set_breeze, level, temperature, activate + ) + + async def set_pollution_settings( + self, + day_pollution_level: str, + night_pollution_level: str, + humidity_control: bool, + airquality_control: bool, + co2_control: str, + co2_threshold: int, + co2_hysteresis: int, + ) -> None: + """Configure pollutions settings.""" + day = Level[str(day_pollution_level).upper()] + night = Level[str(night_pollution_level).upper()] + + await self.api.set_pollution( + day, + night, + humidity_control, + airquality_control, + co2_control, + co2_threshold, + co2_hysteresis, + ) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json index 1a7f367a9464db..fa94207748ed08 100644 --- a/homeassistant/components/renson/manifest.json +++ b/homeassistant/components/renson/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/renson", "iot_class": "local_polling", - "requirements": ["renson-endura-delta==1.6.0"] + "requirements": ["renson-endura-delta==1.7.1"] } diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 004be661f02763..380a83b6818d74 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -52,7 +52,7 @@ from .entity import RensonEntity -@dataclass +@dataclass(frozen=True) class RensonSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -60,7 +60,7 @@ class RensonSensorEntityDescriptionMixin: raw_format: bool -@dataclass +@dataclass(frozen=True) class RensonSensorEntityDescription( SensorEntityDescription, RensonSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/renson/services.yaml b/homeassistant/components/renson/services.yaml new file mode 100644 index 00000000000000..ad79af8649e7e8 --- /dev/null +++ b/homeassistant/components/renson/services.yaml @@ -0,0 +1,117 @@ +set_timer_level: + target: + entity: + integration: renson + domain: fan + fields: + timer_level: + required: true + default: "level1" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + - "holiday" + - "breeze" + minutes: + required: true + default: 0 + selector: + number: + min: 0 + max: 1440 + step: 10 + unit_of_measurement: "min" + mode: slider + +set_breeze: + target: + entity: + integration: renson + domain: fan + fields: + breeze_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + temperature: + default: 18 + selector: + number: + min: 15 + max: 35 + step: 1 + unit_of_measurement: "°C" + mode: slider + activate: + required: true + default: false + selector: + boolean: + +set_pollution_settings: + target: + entity: + integration: renson + domain: fan + fields: + day_pollution_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + night_pollution_level: + default: "level2" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + humidity_control: + default: true + selector: + boolean: + airquality_control: + default: true + selector: + boolean: + co2_control: + default: true + selector: + boolean: + co2_threshold: + default: 600 + selector: + number: + min: 400 + max: 2000 + step: 50 + unit_of_measurement: "ppm" + mode: slider + co2_hysteresis: + default: 100 + selector: + number: + min: 50 + max: 400 + step: 50 + unit_of_measurement: "ppm" + mode: slider diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index d6d03ed1c44a12..a826b5a3dd3c1b 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." } } }, @@ -159,5 +162,86 @@ "name": "Bypass level" } } + }, + "selector": { + "level_setting": { + "options": { + "off": "[%key:common::state::off%]", + "level1": "[%key:component::renson::entity::sensor::ventilation_level::state::level1%]", + "level2": "[%key:component::renson::entity::sensor::ventilation_level::state::level2%]", + "level3": "[%key:component::renson::entity::sensor::ventilation_level::state::level3%]", + "level4": "[%key:component::renson::entity::sensor::ventilation_level::state::level4%]", + "breeze": "[%key:component::renson::entity::sensor::ventilation_level::state::breeze%]", + "holiday": "[%key:component::renson::entity::sensor::ventilation_level::state::holiday%]" + } + } + }, + "services": { + "set_timer_level": { + "name": "Set timer", + "description": "Set the ventilation timer", + "fields": { + "timer_level": { + "name": "Level", + "description": "Level setting" + }, + "minutes": { + "name": "Time", + "description": "Time of the timer (0 will disable the timer)" + } + } + }, + "set_breeze": { + "name": "Set breeze", + "description": "Set the breeze function of the ventilation system", + "fields": { + "breeze_level": { + "name": "[%key:component::renson::services::set_timer_level::fields::timer_level::name%]", + "description": "Ventilation level when breeze function is activated" + }, + "temperature": { + "name": "Temperature", + "description": "Temperature when the breeze function should be activated" + }, + "activate": { + "name": "Activate", + "description": "Activate or disable the breeze feature" + } + } + }, + "set_pollution_settings": { + "name": "Set pollution settings", + "description": "Set all the pollution settings of the ventilation system", + "fields": { + "day_pollution_level": { + "name": "Day pollution Level", + "description": "Ventilation level when pollution is detected in the day" + }, + "night_pollution_level": { + "name": "Night pollution Level", + "description": "Ventilation level when pollution is detected in the night" + }, + "humidity_control": { + "name": "Enable humidity control", + "description": "Activate or disable the humidity control" + }, + "airquality_control": { + "name": "Enable air quality control", + "description": "Activate or disable the air quality control" + }, + "co2_control": { + "name": "Enable CO2 control", + "description": "Activate or disable the CO2 control" + }, + "co2_threshold": { + "name": "CO2 threshold", + "description": "Sets the CO2 pollution threshold level in ppm" + }, + "co2_hysteresis": { + "name": "CO2 hysteresis", + "description": "Sets the CO2 pollution threshold hysteresis level in ppm" + } + } + } } } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 8425f29fbe89e8..028b2c89311b73 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -89,9 +90,9 @@ async def async_device_config_update() -> None: 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 @@ -148,6 +149,15 @@ async def async_check_firmware_update() -> str | Literal[ firmware_coordinator=firmware_coordinator, ) + cleanup_disconnected_cams(hass, config_entry.entry_id, host) + + # Can be remove in HA 2024.6.0 + entity_reg = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_reg, config_entry.entry_id) + for entity in entities: + if entity.domain == "light" and entity.unique_id.endswith("ir_lights"): + entity_reg.async_remove(entity.entity_id) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( @@ -175,3 +185,51 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok + + +def cleanup_disconnected_cams( + hass: HomeAssistant, config_entry_id: str, host: ReolinkHost +) -> None: + """Clean-up disconnected camera channels.""" + if not host.api.is_nvr: + return + + device_reg = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) + for device in devices: + device_id = [ + dev_id[1].split("_ch") + for dev_id in device.identifiers + if dev_id[0] == DOMAIN + ][0] + + if len(device_id) < 2: + # Do not consider the NVR itself + continue + + ch = int(device_id[1]) + ch_model = host.api.camera_model(ch) + remove = False + if ch not in host.api.channels: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, " + "since no camera is connected to NVR channel %s anymore", + device.name, + ch, + ) + if ch_model not in [device.model, "Unknown"]: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, " + "since the camera model connected to channel %s changed from %s to %s", + device.name, + ch, + device.model, + ch_model, + ) + if not remove: + continue + + # clean device registry and associated entities + device_reg.async_remove_device(device.id) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 7f2ff3e0053bfe..03b30d8195eecf 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -25,25 +25,19 @@ from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass -class ReolinkBinarySensorEntityDescriptionMixin: - """Mixin values for Reolink binary sensor entities.""" - - value: Callable[[Host, int], bool] - - -@dataclass +@dataclass(frozen=True, kw_only=True) class ReolinkBinarySensorEntityDescription( - BinarySensorEntityDescription, ReolinkBinarySensorEntityDescriptionMixin + BinarySensorEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes binary sensor entities.""" - icon: str = "mdi:motion-sensor" icon_off: str = "mdi:motion-sensor-off" - supported: Callable[[Host, int], bool] = lambda host, ch: True + icon: str = "mdi:motion-sensor" + value: Callable[[Host, int], bool] BINARY_SENSORS = ( @@ -79,7 +73,18 @@ class ReolinkBinarySensorEntityDescription( 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", @@ -125,8 +130,8 @@ def __init__( entity_description: ReolinkBinarySensorEntityDescription, ) -> None: """Initialize Reolink binary sensor.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: if entity_description.translation_key is not None: @@ -135,10 +140,6 @@ def __init__( key = entity_description.key self._attr_translation_key = f"{key}_lens_{self._channel}" - self._attr_unique_id = ( - f"{self._host.unique_id}_{self._channel}_{entity_description.key}" - ) - @property def icon(self) -> str | None: """Icon of the sensor.""" diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index f179752791413f..5656f178db6b76 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -6,53 +6,58 @@ 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.""" +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) - method: Callable[[Host, int], Any] +ATTR_SPEED = "speed" +SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM -@dataclass +@dataclass(frozen=True, kw_only=True) class ReolinkButtonEntityDescription( - ButtonEntityDescription, ReolinkButtonEntityDescriptionMixin + ButtonEntityDescription, + ReolinkChannelEntityDescription, ): """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] + ptz_cmd: str | None = None -@dataclass -class ReolinkHostButtonEntityDescriptionMixin: - """Mixin values for Reolink button entities for the host.""" - - method: Callable[[Host], Any] - - -@dataclass +@dataclass(frozen=True, kw_only=True) class ReolinkHostButtonEntityDescription( - ButtonEntityDescription, ReolinkHostButtonEntityDescriptionMixin + ButtonEntityDescription, + ReolinkHostEntityDescription, ): """A class that describes button entities for the host.""" - supported: Callable[[Host], bool] = lambda api: True + method: Callable[[Host], Any] BUTTON_ENTITIES = ( @@ -61,8 +66,9 @@ class ReolinkHostButtonEntityDescription( 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 +77,7 @@ class ReolinkHostButtonEntityDescription( 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 +85,7 @@ class ReolinkHostButtonEntityDescription( 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 +93,7 @@ class ReolinkHostButtonEntityDescription( 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 +101,7 @@ class ReolinkHostButtonEntityDescription( 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 +110,7 @@ class ReolinkHostButtonEntityDescription( 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 +119,7 @@ class ReolinkHostButtonEntityDescription( 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 +181,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.""" @@ -182,20 +202,36 @@ def __init__( entity_description: ReolinkButtonEntityDescription, ) -> None: """Initialize Reolink button entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) if entity_description.enabled_default is not None: self._attr_entity_registry_enabled_default = ( 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): @@ -209,11 +245,12 @@ def __init__( entity_description: ReolinkHostButtonEntityDescription, ) -> None: """Initialize Reolink button entity.""" - super().__init__(reolink_data) self.entity_description = entity_description - - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + super().__init__(reolink_data) 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 b012649ec4c5ce..715588a82251c5 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,22 +1,93 @@ """Component providing support for Reolink IP cameras.""" from __future__ import annotations +from dataclasses import dataclass import logging from reolink_aio.api import DUAL_LENS_MODELS +from reolink_aio.exceptions import ReolinkError -from homeassistant.components.camera import Camera, 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 from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class ReolinkCameraEntityDescription( + CameraEntityDescription, + ReolinkChannelEntityDescription, +): + """A class that describes camera entities for a camera channel.""" + + stream: str + + +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 +95,58 @@ 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"] + 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 ) - - for stream in streams: - stream_url = await host.api.get_stream_source(channel, stream) - if stream_url is None and "snapshots" not in 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}" + ) 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/config_flow.py b/homeassistant/components/reolink/config_flow.py index a27c84b9593f9f..fc9b717f89b86a 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -10,13 +10,19 @@ from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac -from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN +from .const import CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkException, ReolinkWebhookException, UserNotAdmin from .host import ReolinkHost from .util import is_connected diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 2a35a0f723d83c..8aa01bfac417f0 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,4 +3,3 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" -CONF_PROTOCOL = "protocol" diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py new file mode 100644 index 00000000000000..04b476296f876f --- /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 e7d62c9705a1e8..042e6b45717263 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,11 +1,14 @@ """Reolink parent entity class.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import TypeVar -from reolink_aio.api import DUAL_LENS_MODELS +from reolink_aio.api import DUAL_LENS_MODELS, Host from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -17,8 +20,24 @@ _T = TypeVar("_T") +@dataclass(frozen=True, kw_only=True) +class ReolinkChannelEntityDescription(EntityDescription): + """A class that describes entities for a camera channel.""" + + cmd_key: str | None = None + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +@dataclass(frozen=True, kw_only=True) +class ReolinkHostEntityDescription(EntityDescription): + """A class that describes host entities.""" + + cmd_key: str | None = None + supported: Callable[[Host], bool] = lambda api: True + + class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]): - """Parent class fo Reolink entities.""" + """Parent class for Reolink entities.""" _attr_has_entity_name = True @@ -42,6 +61,7 @@ def __init__( 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, ) @@ -58,14 +78,29 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): basically a NVR with a single channel that has the camera connected to that channel. """ + entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription + def __init__(self, reolink_data: ReolinkData) -> None: """Initialize ReolinkHostCoordinatorEntity.""" super().__init__(reolink_data, reolink_data.device_coordinator) + self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + if ( + self.entity_description.cmd_key is not None + and self.entity_description.cmd_key not in self._host.update_cmd_list + ): + self._host.update_cmd_list.append(self.entity_description.cmd_key) + class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" + entity_description: ReolinkChannelEntityDescription + def __init__( self, reolink_data: ReolinkData, @@ -75,6 +110,9 @@ def __init__( super().__init__(reolink_data) self._channel = channel + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{self.entity_description.key}" + ) dev_ch = channel if self._host.api.model in DUAL_LENS_MODELS: @@ -87,5 +125,6 @@ def __init__( 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 0075bbac4e6b9a..77aeffd541219e 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -13,7 +13,13 @@ from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError from homeassistant.components import webhook -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import format_mac @@ -21,7 +27,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url -from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN +from .const import CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 30 @@ -60,6 +66,8 @@ def __init__( timeout=DEFAULT_TIMEOUT, ) + self.update_cmd_list: list[str] = [] + self.webhook_id: str | None = None self._onvif_push_supported: bool = True self._onvif_long_poll_supported: bool = True @@ -163,7 +171,7 @@ async def async_init(self) -> None: 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() @@ -311,7 +319,7 @@ async def _async_check_onvif_long_poll(self, *_) -> None: async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" - await self._api.get_states() + await self._api.get_states(cmd_list=self.update_cmd_list) async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" @@ -661,3 +669,12 @@ def _signal_write_ha_state(self, channels: list[int] | None) -> None: 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 938093df4a3634..222ab984e3f3e0 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -6,6 +6,7 @@ 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,58 +17,46 @@ 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 from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@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 +@dataclass(frozen=True, kw_only=True) class ReolinkLightEntityDescription( - LightEntityDescription, ReolinkLightEntityDescriptionMixin + LightEntityDescription, + ReolinkChannelEntityDescription, ): """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 + turn_on_off_fn: Callable[[Host, int, bool], Any] LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", + cmd_key="GetWhiteLed", translation_key="floodlight", icon="mdi:spotlight-beam", - supported_fn=lambda api, ch: api.supported(ch, "floodLight"), + supported=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), turn_on_off_fn=lambda api, ch, value: api.set_whiteled(ch, state=value), get_brightness_fn=lambda api, ch: api.whiteled_brightness(ch), set_brightness_fn=lambda api, ch, value: api.set_whiteled(ch, brightness=value), ), - ReolinkLightEntityDescription( - key="ir_lights", - translation_key="ir_lights", - icon="mdi:led-off", - entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), - is_on_fn=lambda api, ch: api.ir_enabled(ch), - turn_on_off_fn=lambda api, ch, value: api.set_ir_lights(ch, value), - ), ReolinkLightEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "power_led"), + supported=lambda api, ch: api.supported(ch, "power_led"), is_on_fn=lambda api, ch: api.status_led_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_status_led(ch, value), ), @@ -86,7 +75,7 @@ async def async_setup_entry( ReolinkLightEntity(reolink_data, channel, entity_description) for entity_description in LIGHT_ENTITIES for channel in reolink_data.host.api.channels - if entity_description.supported_fn(reolink_data.host.api, channel) + if entity_description.supported(reolink_data.host.api, channel) ) @@ -102,12 +91,8 @@ def __init__( entity_description: ReolinkLightEntityDescription, ) -> None: """Initialize Reolink light entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) if entity_description.set_brightness_fn is None: self._attr_supported_color_modes = {ColorMode.ONOFF} @@ -137,9 +122,12 @@ def brightness(self) -> int | None: 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 +136,19 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 + + try: + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, True ) - - 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 58785c1d795049..d5116af007110c 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.15"] + "requirements": ["reolink-aio==0.8.5"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py new file mode 100644 index 00000000000000..2a1eee9e97dc25 --- /dev/null +++ b/homeassistant/components/reolink/media_source.py @@ -0,0 +1,339 @@ +"""Expose Reolink IP camera VODs as media sources.""" + +from __future__ import annotations + +import datetime as dt +import logging + +from reolink_aio.enums import VodRequestType + +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 + + vod_type = VodRequestType.RTMP + if host.api.is_nvr: + vod_type = VodRequestType.FLV + + mime_type, url = await host.api.get_vod_source( + channel, filename, stream_res, vod_type + ) + 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 6be0cef1670b02..b27976eaa0e618 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -6,6 +6,7 @@ from typing import Any from reolink_aio.api import Host +from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.number import ( NumberEntity, @@ -15,36 +16,32 @@ 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 from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass -class ReolinkNumberEntityDescriptionMixin: - """Mixin values for Reolink number entities.""" - - value: Callable[[Host, int], float | None] - method: Callable[[Host, int, float], Any] - - -@dataclass +@dataclass(frozen=True, kw_only=True) class ReolinkNumberEntityDescription( - NumberEntityDescription, ReolinkNumberEntityDescriptionMixin + NumberEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes number entities.""" - 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 + get_min_value: Callable[[Host, int], float] | None = None + method: Callable[[Host, int, float], Any] + mode: NumberMode = NumberMode.AUTO + value: Callable[[Host, int], float | None] NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="zoom", + cmd_key="GetZoomFocus", translation_key="zoom", icon="mdi:magnify", mode=NumberMode.SLIDER, @@ -57,6 +54,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="focus", + cmd_key="GetZoomFocus", translation_key="focus", icon="mdi:focus-field", mode=NumberMode.SLIDER, @@ -72,6 +70,7 @@ class ReolinkNumberEntityDescription( # or when using the "light.floodlight" entity. ReolinkNumberEntityDescription( key="floodlight_brightness", + cmd_key="GetWhiteLed", translation_key="floodlight_brightness", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -84,6 +83,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="volume", + cmd_key="GetAudioCfg", translation_key="volume", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -96,6 +96,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="guard_return_time", + cmd_key="GetPtzGuard", translation_key="guard_return_time", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -109,6 +110,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="motion_sensitivity", + cmd_key="GetMdAlarm", translation_key="motion_sensitivity", icon="mdi:motion-sensor", entity_category=EntityCategory.CONFIG, @@ -121,6 +123,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="ai_face_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_face_sensititvity", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -135,6 +138,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="ai_person_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_person_sensititvity", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -149,6 +153,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="ai_vehicle_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_sensititvity", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -163,6 +168,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_pet_sensititvity", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -170,13 +176,31 @@ class ReolinkNumberEntityDescription( 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", + cmd_key="GetAiAlarm", + 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"), ), ReolinkNumberEntityDescription( key="ai_face_delay", + cmd_key="GetAiAlarm", translation_key="ai_face_delay", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -193,6 +217,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="ai_person_delay", + cmd_key="GetAiAlarm", translation_key="ai_person_delay", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -209,6 +234,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="ai_vehicle_delay", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_delay", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -225,6 +251,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_pet_delay", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -234,13 +261,33 @@ class ReolinkNumberEntityDescription( 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", + cmd_key="GetAiAlarm", + 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"), ), ReolinkNumberEntityDescription( key="auto_quick_reply_time", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_time", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -254,6 +301,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="auto_track_limit_left", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_left", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -267,6 +315,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="auto_track_limit_right", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_right", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -280,6 +329,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="auto_track_disappear_time", + cmd_key="GetAiCfg", translation_key="auto_track_disappear_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -295,6 +345,7 @@ class ReolinkNumberEntityDescription( ), ReolinkNumberEntityDescription( key="auto_track_stop_time", + cmd_key="GetAiCfg", translation_key="auto_track_stop_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -306,6 +357,90 @@ class ReolinkNumberEntityDescription( 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", + cmd_key="GetIsp", + 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)), + ), + ReolinkNumberEntityDescription( + key="image_brightness", + cmd_key="GetImage", + translation_key="image_brightness", + icon="mdi:image-edit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=255, + supported=lambda api, ch: api.supported(ch, "isp_bright"), + value=lambda api, ch: api.image_brightness(ch), + method=lambda api, ch, value: api.set_image(ch, bright=int(value)), + ), + ReolinkNumberEntityDescription( + key="image_contrast", + cmd_key="GetImage", + translation_key="image_contrast", + icon="mdi:image-edit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=255, + supported=lambda api, ch: api.supported(ch, "isp_contrast"), + value=lambda api, ch: api.image_contrast(ch), + method=lambda api, ch, value: api.set_image(ch, contrast=int(value)), + ), + ReolinkNumberEntityDescription( + key="image_saturation", + cmd_key="GetImage", + translation_key="image_saturation", + icon="mdi:image-edit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=255, + supported=lambda api, ch: api.supported(ch, "isp_satruation"), + value=lambda api, ch: api.image_saturation(ch), + method=lambda api, ch, value: api.set_image(ch, saturation=int(value)), + ), + ReolinkNumberEntityDescription( + key="image_sharpness", + cmd_key="GetImage", + translation_key="image_sharpness", + icon="mdi:image-edit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=255, + supported=lambda api, ch: api.supported(ch, "isp_sharpen"), + value=lambda api, ch: api.image_sharpness(ch), + method=lambda api, ch, value: api.set_image(ch, sharpen=int(value)), + ), + ReolinkNumberEntityDescription( + key="image_hue", + cmd_key="GetImage", + translation_key="image_hue", + icon="mdi:image-edit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=255, + supported=lambda api, ch: api.supported(ch, "isp_hue"), + value=lambda api, ch: api.image_hue(ch), + method=lambda api, ch, value: api.set_image(ch, hue=int(value)), + ), ) @@ -337,8 +472,8 @@ def __init__( entity_description: ReolinkNumberEntityDescription, ) -> None: """Initialize Reolink number entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) if entity_description.get_min_value is not None: self._attr_native_min_value = entity_description.get_min_value( @@ -349,9 +484,6 @@ def __init__( self._host.api, channel ) self._attr_mode = entity_description.mode - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) @property def native_value(self) -> float | None: @@ -360,5 +492,10 @@ def native_value(self) -> float | None: 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 fd42e69268d249..769ccdf7e01912 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -13,41 +13,38 @@ 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 from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription _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 +@dataclass(frozen=True, kw_only=True) class ReolinkSelectEntityDescription( - SelectEntityDescription, ReolinkSelectEntityDescriptionMixin + SelectEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes select entities.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True + get_options: list[str] | Callable[[Host, int], list[str]] + method: Callable[[Host, int, str], Any] value: Callable[[Host, int], str] | None = None SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="floodlight_mode", + cmd_key="GetWhiteLed", translation_key="floodlight_mode", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -58,6 +55,7 @@ class ReolinkSelectEntityDescription( ), ReolinkSelectEntityDescription( key="day_night_mode", + cmd_key="GetIsp", translation_key="day_night_mode", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, @@ -76,6 +74,7 @@ class ReolinkSelectEntityDescription( ), ReolinkSelectEntityDescription( key="auto_quick_reply_message", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_message", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -88,6 +87,7 @@ class ReolinkSelectEntityDescription( ), ReolinkSelectEntityDescription( key="auto_track_method", + cmd_key="GetAiCfg", translation_key="auto_track_method", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -98,6 +98,7 @@ class ReolinkSelectEntityDescription( ), ReolinkSelectEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, @@ -137,14 +138,10 @@ def __init__( entity_description: ReolinkSelectEntityDescription, ) -> None: """Initialize Reolink select entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) self._log_error = True - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) - if callable(entity_description.get_options): self._attr_options = entity_description.get_options(self._host.api, channel) else: @@ -169,5 +166,10 @@ def current_option(self) -> str | None: 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 b9e8ddb8e73195..6f4af489fe5018 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -21,44 +21,38 @@ from . import ReolinkData 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] +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) -@dataclass +@dataclass(frozen=True, kw_only=True) class ReolinkSensorEntityDescription( - SensorEntityDescription, ReolinkSensorEntityDescriptionMixin + SensorEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes sensor entities for a camera channel.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True - - -@dataclass -class ReolinkHostSensorEntityDescriptionMixin: - """Mixin values for Reolink host sensor entities.""" - - value: Callable[[Host], int | None] + value: Callable[[Host, int], int] -@dataclass +@dataclass(frozen=True, kw_only=True) class ReolinkHostSensorEntityDescription( - SensorEntityDescription, ReolinkHostSensorEntityDescriptionMixin + SensorEntityDescription, + ReolinkHostEntityDescription, ): """A class that describes host sensor entities.""" - supported: Callable[[Host], bool] = lambda api: True + value: Callable[[Host], int | None] SENSORS = ( ReolinkSensorEntityDescription( key="ptz_pan_position", + cmd_key="GetPtzCurPos", translation_key="ptz_pan_position", icon="mdi:pan", state_class=SensorStateClass.MEASUREMENT, @@ -71,6 +65,7 @@ class ReolinkHostSensorEntityDescription( HOST_SENSORS = ( ReolinkHostSensorEntityDescription( key="wifi_signal", + cmd_key="GetWifiSignal", translation_key="wifi_signal", icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, @@ -118,12 +113,8 @@ def __init__( entity_description: ReolinkSensorEntityDescription, ) -> None: """Initialize Reolink sensor.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) @property def native_value(self) -> StateType | date | datetime | Decimal: @@ -142,10 +133,8 @@ def __init__( entity_description: ReolinkHostSensorEntityDescription, ) -> None: """Initialize Reolink host sensor.""" - super().__init__(reolink_data) self.entity_description = entity_description - - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + super().__init__(reolink_data) @property def native_value(self) -> StateType | date | datetime | Decimal: diff --git a/homeassistant/components/reolink/services.yaml b/homeassistant/components/reolink/services.yaml new file mode 100644 index 00000000000000..42b9af34eb0e06 --- /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 c91f633ecab7c0..90590acb4e4bcc 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -1,11 +1,10 @@ """Component providing support for Reolink siren entities.""" from __future__ import annotations -from collections.abc import Callable 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,19 +15,20 @@ ) 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 from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass -class ReolinkSirenEntityDescription(SirenEntityDescription): +@dataclass(frozen=True) +class ReolinkSirenEntityDescription( + SirenEntityDescription, ReolinkChannelEntityDescription +): """A class that describes siren entities.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True - SIREN_ENTITIES = ( ReolinkSirenEntityDescription( @@ -74,20 +74,29 @@ def __init__( entity_description: ReolinkSirenEntityDescription, ) -> None: """Initialize Reolink siren entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) 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 0a496d62522b00..92e9a6164f8649 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -10,6 +10,13 @@ "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'.", + "port": "The port to connect to the Reolink device. For HTTP normally: '80', for HTTPS normally '443'.", + "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", + "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } }, "reauth_confirm": { @@ -35,6 +42,9 @@ "init": { "data": { "protocol": "Protocol" + }, + "data_description": { + "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (h265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera." } } } @@ -61,6 +71,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 +97,9 @@ "pet": { "name": "Pet" }, + "animal": { + "name": "Animal" + }, "visitor": { "name": "Visitor" }, @@ -93,6 +118,9 @@ "pet_lens_0": { "name": "Pet lens 0" }, + "animal_lens_0": { + "name": "Animal lens 0" + }, "visitor_lens_0": { "name": "Visitor lens 0" }, @@ -111,6 +139,9 @@ "pet_lens_1": { "name": "Pet lens 1" }, + "animal_lens_1": { + "name": "Animal lens 1" + }, "visitor_lens_1": { "name": "Visitor lens 1" } @@ -147,13 +178,66 @@ "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" }, - "ir_lights": { - "name": "Infra red lights in night mode" - }, "status_led": { "name": "Status LED" } @@ -189,6 +273,9 @@ "ai_pet_sensititvity": { "name": "AI pet sensitivity" }, + "ai_animal_sensititvity": { + "name": "AI animal sensitivity" + }, "ai_face_delay": { "name": "AI face delay" }, @@ -201,6 +288,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 +305,24 @@ }, "auto_track_stop_time": { "name": "Auto track stop time" + }, + "day_night_switch_threshold": { + "name": "Day night switch threshold" + }, + "image_brightness": { + "name": "Image brightness" + }, + "image_contrast": { + "name": "Image contrast" + }, + "image_saturation": { + "name": "Image saturation" + }, + "image_sharpness": { + "name": "Image sharpness" + }, + "image_hue": { + "name": "Image hue" } }, "select": { @@ -234,7 +342,7 @@ "state": { "auto": "Auto", "color": "Color", - "blackwhite": "Black&White" + "blackwhite": "Black & white" } }, "ptz_preset": { @@ -277,6 +385,9 @@ } }, "switch": { + "ir_lights": { + "name": "Infra red lights in night mode" + }, "record_audio": { "name": "Record audio" }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index f07db00e720e17..7f57b78df1e366 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -6,55 +6,61 @@ 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 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] +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) -@dataclass +@dataclass(frozen=True, kw_only=True) class ReolinkSwitchEntityDescription( - SwitchEntityDescription, ReolinkSwitchEntityDescriptionMixin + SwitchEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes switch entities.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True - - -@dataclass -class ReolinkNVRSwitchEntityDescriptionMixin: - """Mixin values for Reolink NVR switch entities.""" - - value: Callable[[Host], bool] - method: Callable[[Host, bool], Any] + method: Callable[[Host, int, bool], Any] + value: Callable[[Host, int], bool] -@dataclass +@dataclass(frozen=True, kw_only=True) class ReolinkNVRSwitchEntityDescription( - SwitchEntityDescription, ReolinkNVRSwitchEntityDescriptionMixin + SwitchEntityDescription, + ReolinkHostEntityDescription, ): """A class that describes NVR switch entities.""" - supported: Callable[[Host], bool] = lambda api: True + method: Callable[[Host, bool], Any] + value: Callable[[Host], bool] SWITCH_ENTITIES = ( + ReolinkSwitchEntityDescription( + key="ir_lights", + cmd_key="GetIrLights", + translation_key="ir_lights", + icon="mdi:led-off", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ir_lights"), + value=lambda api, ch: api.ir_enabled(ch), + method=lambda api, ch, value: api.set_ir_lights(ch, value), + ), ReolinkSwitchEntityDescription( key="record_audio", + cmd_key="GetEnc", translation_key="record_audio", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, @@ -64,6 +70,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="siren_on_event", + cmd_key="GetAudioAlarm", translation_key="siren_on_event", icon="mdi:alarm-light", entity_category=EntityCategory.CONFIG, @@ -73,6 +80,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="auto_tracking", + cmd_key="GetAiCfg", translation_key="auto_tracking", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -82,6 +90,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="auto_focus", + cmd_key="GetAutoFocus", translation_key="auto_focus", icon="mdi:focus-field", entity_category=EntityCategory.CONFIG, @@ -91,6 +100,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="gaurd_return", + cmd_key="GetPtzGuard", translation_key="gaurd_return", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -100,6 +110,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -109,6 +120,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -118,6 +130,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -127,6 +140,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -136,6 +150,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, @@ -145,6 +160,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="doorbell_button_sound", + cmd_key="GetAudioCfg", translation_key="doorbell_button_sound", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -154,6 +170,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkSwitchEntityDescription( key="hdr", + cmd_key="GetIsp", translation_key="hdr", icon="mdi:hdr", entity_category=EntityCategory.CONFIG, @@ -167,6 +184,7 @@ class ReolinkNVRSwitchEntityDescription( NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -176,6 +194,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkNVRSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -185,6 +204,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkNVRSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -194,6 +214,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkNVRSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -203,6 +224,7 @@ class ReolinkNVRSwitchEntityDescription( ), ReolinkNVRSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, @@ -249,12 +271,8 @@ def __init__( entity_description: ReolinkSwitchEntityDescription, ) -> None: """Initialize Reolink switch entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) @property def is_on(self) -> bool: @@ -263,12 +281,18 @@ def is_on(self) -> bool: 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() @@ -283,8 +307,8 @@ def __init__( entity_description: ReolinkNVRSwitchEntityDescription, ) -> None: """Initialize Reolink switch entity.""" - super().__init__(reolink_data) self.entity_description = entity_description + super().__init__(reolink_data) self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" @@ -295,10 +319,16 @@ def is_on(self) -> bool: 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 1c10671550d667..ffd429e92ad6bd 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 @@ 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 @@ LOGGER = logging.getLogger(__name__) +POLL_AFTER_INSTALL = 120 + async def async_setup_entry( hass: HomeAssistant, @@ -51,6 +55,7 @@ def __init__( 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 @@ async def async_install( 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/__init__.py b/homeassistant/components/repetier/__init__.py index 6a0df99ce1db7f..91a16ea3fbebf0 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -124,14 +124,14 @@ def has_all_unique_names(value): return value -@dataclass +@dataclass(frozen=True) class RepetierRequiredKeysMixin: """Mixin for required keys.""" type: str -@dataclass +@dataclass(frozen=True) class RepetierSensorEntityDescription( SensorEntityDescription, RepetierRequiredKeysMixin ): diff --git a/homeassistant/components/repetier/manifest.json b/homeassistant/components/repetier/manifest.json index 5ad3db89ba011b..dfddb29828493a 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/rest/switch.py b/homeassistant/components/rest/switch.py index 102bb0249249df..7dbe295afee18c 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -171,7 +171,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: try: req = await self.set_device_state(body_on_t) - if req.status_code == HTTPStatus.OK: + if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: self._attr_is_on = True else: _LOGGER.error( @@ -186,7 +186,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: try: req = await self.set_device_state(body_off_t) - if req.status_code == HTTPStatus.OK: + if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: self._attr_is_on = False else: _LOGGER.error( @@ -202,22 +202,22 @@ async def set_device_state(self, body: Any) -> httpx.Response: rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - async with asyncio.timeout(self._timeout): - req: httpx.Response = await getattr(websession, self._method)( - self._resource, - auth=self._auth, - content=bytes(body, "utf-8"), - headers=rendered_headers, - params=rendered_params, - ) - return req + req: httpx.Response = await getattr(websession, self._method)( + self._resource, + auth=self._auth, + content=bytes(body, "utf-8"), + headers=rendered_headers, + params=rendered_params, + timeout=self._timeout, + ) + return req async def async_update(self) -> None: """Get the current state, catching errors.""" req = None try: req = await self.get_device_state(self.hass) - except asyncio.TimeoutError: + except (asyncio.TimeoutError, httpx.TimeoutException): _LOGGER.exception("Timed out while fetching data") except httpx.RequestError as err: _LOGGER.exception("Error while fetching data: %s", err) @@ -233,14 +233,14 @@ async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - async with asyncio.timeout(self._timeout): - req = await websession.get( - self._state_resource, - auth=self._auth, - headers=rendered_headers, - params=rendered_params, - ) - text = req.text + req = await websession.get( + self._state_resource, + auth=self._auth, + headers=rendered_headers, + params=rendered_params, + timeout=self._timeout, + ) + text = req.text if self._is_on_template is not None: text = self._is_on_template.async_render_with_possible_json_value( diff --git a/homeassistant/components/rest_command/manifest.json b/homeassistant/components/rest_command/manifest.json index f9acf3b59330dc..bd3b60706918eb 100644 --- a/homeassistant/components/rest_command/manifest.json +++ b/homeassistant/components/rest_command/manifest.json @@ -1,7 +1,7 @@ { "domain": "rest_command", "name": "RESTful Command", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/rest_command", "iot_class": "local_push" } diff --git a/homeassistant/components/rest_command/services.yaml b/homeassistant/components/rest_command/services.yaml index e69de29bb2d1d6..c983a105c93977 100644 --- a/homeassistant/components/rest_command/services.yaml +++ b/homeassistant/components/rest_command/services.yaml @@ -0,0 +1 @@ +reload: diff --git a/homeassistant/components/rest_command/strings.json b/homeassistant/components/rest_command/strings.json new file mode 100644 index 00000000000000..15f59ec8e291a6 --- /dev/null +++ b/homeassistant/components/rest_command/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads RESTful commands from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 9c5ffa586cd5ce..cfacc62774403a 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Mapping import copy import logging -from typing import Any, NamedTuple, cast +from typing import Any, NamedTuple, TypeVarTuple, cast import RFXtrx as rfxtrxmod import voluptuous as vol @@ -50,6 +50,8 @@ SIGNAL_EVENT = f"{DOMAIN}_event" +_Ts = TypeVarTuple("_Ts") + _LOGGER = logging.getLogger(__name__) @@ -145,7 +147,7 @@ def _create_rfx(config: Mapping[str, Any]) -> rfxtrxmod.Connect: def _get_device_lookup( - devices: dict[str, dict[str, Any]] + devices: dict[str, dict[str, Any]], ) -> dict[DeviceTuple, dict[str, Any]]: """Get a lookup structure for devices.""" lookup = {} @@ -438,7 +440,7 @@ def get_device_id( def get_device_tuple_from_identifiers( - identifiers: set[tuple[str, str]] + identifiers: set[tuple[str, str]], ) -> DeviceTuple | None: """Calculate the device tuple from a device entry.""" identifier = next((x for x in identifiers if x[0] == DOMAIN and len(x) == 4), None) @@ -559,6 +561,8 @@ def __init__( """Initialzie a switch or light device.""" super().__init__(device, device_id, event=event) - async def _async_send(self, fun: Callable[..., None], *args: Any) -> None: - rfx_object = self.hass.data[DOMAIN][DATA_RFXOBJECT] + async def _async_send( + self, fun: Callable[[rfxtrxmod.PySerialTransport, *_Ts], None], *args: *_Ts + ) -> None: + rfx_object: rfxtrxmod.Connect = self.hass.data[DOMAIN][DATA_RFXOBJECT] await self.hass.async_add_executor_job(fun, rfx_object.transport, *args) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 179dd04cfaaa6c..12b9290af99f64 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -566,10 +566,9 @@ async def async_step_setup_serial( 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 @@ -644,7 +643,7 @@ def _test_transport(host: str | None, port: int | None, device: str | None) -> b else: try: conn = rfxtrxmod.PySerialTransport(device) - except serial.serialutil.SerialException: + except serial.SerialException: return False if conn.serial is None: diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 60f35a93d1a89e..66803edffc5d32 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -58,7 +58,7 @@ def _rssi_convert(value: int | None) -> str | None: return f"{value*8-120}" -@dataclass +@dataclass(frozen=True) class RfxtrxSensorEntityDescription(SensorEntityDescription): """Description of sensor entities.""" diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 85ddf559cf51dc..9b99553d3f0dbd 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 56aad1a845b4b5..8a93d5a776885b 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -8,35 +8,31 @@ 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 -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Data provided by Ring.com" - -NOTIFICATION_ID = "ring_notification" -NOTIFICATION_TITLE = "Ring Setup" +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, +) -DOMAIN = "ring" -DEFAULT_ENTITY_NAMESPACE = "ring" - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.LIGHT, - Platform.SENSOR, - Platform.SWITCH, - Platform.CAMERA, - Platform.SIREN, -] +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -44,53 +40,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def token_updater(token): """Handle from sync context when token is updated.""" - run_callback_threadsafe( - hass.loop, + hass.loop.call_soon_threadsafe( partial( hass.config_entries.async_update_entry, entry, - data={**entry.data, "token": token}, - ), - ).result() + data={**entry.data, CONF_TOKEN: token}, + ) + ) - 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 +140,15 @@ def __init__( 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 +184,19 @@ async def async_refresh_all(self, _now: int | None = None) -> None: 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 +215,15 @@ def __init__( 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 +275,22 @@ def refresh_all(self, _=None): 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 ab7207f0ac4e09..27eb82d34eea00 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -14,18 +14,18 @@ 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 -@dataclass +@dataclass(frozen=True) class RingRequiredKeysMixin: """Mixin for required keys.""" category: list[str] -@dataclass +@dataclass(frozen=True) class RingBinarySensorEntityDescription( BinarySensorEntityDescription, RingRequiredKeysMixin ): @@ -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 @@ def __init__( 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 7f897d172035d1..196d34600d143d 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -16,7 +16,7 @@ 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 @@ async def async_added_to_hass(self) -> None: """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 @@ async def async_will_remove_from_hass(self) -> None: """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 9425b2f98a4aa7..5c735a3ee8c102 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 . import DOMAIN +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 .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 @@ async def async_step_user(self, user_input=None): 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 00000000000000..10d517ab4a35c8 --- /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 00000000000000..105800f8d13b14 --- /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 7160d2ef7259bd..4896ea2db8bfc5 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -3,7 +3,7 @@ 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 @@ def __init__(self, config_entry_id, device): 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 93640e2764e6b5..7830b2547a5b90 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -11,7 +11,7 @@ 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 9cea738eb3a632..85cab6f1763c5e 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -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.5"] } diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index af23af07ebab18..a596d413ac7f4b 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -14,7 +14,12 @@ 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 @@ async def async_added_to_hass(self) -> None: """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 @@ async def async_will_remove_from_hass(self) -> None: """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 @@ async def async_added_to_hass(self) -> None: """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 @@ async def async_will_remove_from_hass(self) -> None: """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 ) @@ -168,7 +173,7 @@ def extra_state_attributes(self): return attrs -@dataclass +@dataclass(frozen=True) class RingRequiredKeysMixin: """Mixin for required keys.""" @@ -176,7 +181,7 @@ class RingRequiredKeysMixin: cls: type[RingSensor] -@dataclass +@dataclass(frozen=True) class RingSensorEntityDescription(SensorEntityDescription, RingRequiredKeysMixin): """Describes Ring sensor entity.""" diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 7f1b147471d271..7daf7bd69ca901 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -9,7 +9,7 @@ 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 b300e335b191f7..688e3141beb502 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 7069acd5f0feb1..074dfee9bd655e 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -11,7 +11,7 @@ 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 88f8ba9bdfa79b..9c62447ee047f2 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -35,10 +35,12 @@ 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]) - - try: - await risco.connect() - except CannotConnectError as error: - raise ConfigEntryNotReady() from error - except UnauthorizedError: - _LOGGER.exception("Failed to login to Risco cloud") - return False + comm_delay = initial_delay = data.get(CONF_COMMUNICATION_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 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 0f532a376a1f96..ef96714742d0c6 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -28,10 +28,12 @@ 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 @@ async def async_step_local(self, user_input=None): 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 9f0e71701c65b9..800003d2384b84 100644 --- a/homeassistant/components/risco/const.py +++ b/homeassistant/components/risco/const.py @@ -17,10 +17,13 @@ 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 5b208d1fc187d1..ca28af3d8e55c5 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 73499fb5cccc8d..f33a687b88fd3e 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -21,21 +21,14 @@ from .entity import DiffuserEntity -@dataclass -class RitualsentityDescriptionMixin: - """Mixin values for Rituals entities.""" +@dataclass(frozen=True, 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 3e6af33315f658..164b6de52c9f81 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -17,21 +17,14 @@ from .entity import DiffuserEntity -@dataclass -class RitualsNumberEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, 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 42e18624d13b5a..b9f0c29b267d4e 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -17,21 +17,14 @@ from .entity import DiffuserEntity -@dataclass -class RitualsEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, 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 09189dabfad62a..cd139c94f1c84f 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -21,20 +21,12 @@ from .entity import DiffuserEntity -@dataclass -class RitualsEntityDescriptionMixin: - """Mixin values for Rituals entities.""" - - value_fn: Callable[[Diffuser], int | str] - - -@dataclass -class RitualsSensorEntityDescription( - SensorEntityDescription, RitualsEntityDescriptionMixin -): +@dataclass(frozen=True, 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/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 77776704a6053e..9c9a5f73d165b5 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -17,7 +17,7 @@ from .entity import DiffuserEntity -@dataclass +@dataclass(frozen=True) class RitualsEntityDescriptionMixin: """Mixin values for Rituals entities.""" @@ -26,7 +26,7 @@ class RitualsEntityDescriptionMixin: turn_off_fn: Callable[[Diffuser], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class RitualsSwitchEntityDescription( SwitchEntityDescription, RitualsEntityDescriptionMixin ): diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index b310b2bb2ba977..ff49b352c18a5f 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() - } - 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 + product_info: dict[str, HomeDataProduct] = { + product.id: product for product in home_data.products } - 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 203f981e51d193..03e1eabe45a035 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -22,14 +22,14 @@ from .device import RoborockCoordinatedEntity -@dataclass +@dataclass(frozen=True) class RoborockBinarySensorDescriptionMixin: """A class that describes binary sensor entities.""" value_fn: Callable[[DeviceProp], bool | int | None] -@dataclass +@dataclass(frozen=True) class RoborockBinarySensorDescription( BinarySensorEntityDescription, RoborockBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index aba86ccb6b60d1..7744c5988d8928 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -17,7 +17,7 @@ from .device import RoborockEntity -@dataclass +@dataclass(frozen=True) class RoborockButtonDescriptionMixin: """Define an entity description mixin for button entities.""" @@ -25,7 +25,7 @@ class RoborockButtonDescriptionMixin: param: list | dict | None -@dataclass +@dataclass(frozen=True) class RoborockButtonDescription( ButtonEntityDescription, RoborockButtonDescriptionMixin ): diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index fcfad6e8cd36d6..201631f0825c9d 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 @@ 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 @@ async def async_step_user( 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 @@ async def async_step_user( 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 @@ async def async_step_code( _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 @@ async def async_step_code( 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 d135f323e90347..d7a3a9229f56d3 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" @@ -9,6 +11,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.BINARY_SENSOR, + Platform.IMAGE, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, @@ -16,3 +19,13 @@ Platform.TIME, 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 30bfc71ea4857c..cd08cf871d4cb2 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -55,6 +55,7 @@ def __init__( 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)} @@ -91,6 +92,18 @@ async def _async_update_data(self) -> 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 c8f45b40d82e6c..71376dd600ec7b 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 @@ -82,6 +83,11 @@ def _device_status(self) -> Status: data = self.coordinator.data return data.status + @property + def cloud_api(self) -> RoborockMqttClient: + """Return the cloud api.""" + return self.coordinator.cloud_api + async def send( self, command: RoborockCommand | str, @@ -91,3 +97,12 @@ async def send( 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 00000000000000..5e61bb1d408e4a --- /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 ed043582a0e128..c149b9fcf7f10c 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.36.1"] + "requirements": [ + "python-roborock==0.38.0", + "vacuum-map-parser-roborock==0.1.1" + ] } diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index d91606418d95a7..8957c487a64f0b 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RoborockNumberDescriptionMixin: """Define an entity description mixin for button entities.""" @@ -33,7 +33,7 @@ class RoborockNumberDescriptionMixin: update_value: Callable[[AttributeCache, float], Coroutine[Any, Any, dict]] -@dataclass +@dataclass(frozen=True) class RoborockNumberDescription( NumberEntityDescription, RoborockNumberDescriptionMixin ): diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index f4968bf7db96f5..ae5dd12689d796 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -3,6 +3,7 @@ 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 @@ -17,7 +18,7 @@ from .device import RoborockCoordinatedEntity -@dataclass +@dataclass(frozen=True) class RoborockSelectDescriptionMixin: """Define an entity description mixin for select entities.""" @@ -31,12 +32,14 @@ class RoborockSelectDescriptionMixin: parameter_lambda: Callable[[str, Status], list[int]] -@dataclass +@dataclass(frozen=True) class RoborockSelectDescription( SelectEntityDescription, RoborockSelectDescriptionMixin ): """Class to describe an Roborock select entity.""" + protocol_listener: RoborockDataProtocol | None = None + SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ RoborockSelectDescription( @@ -49,6 +52,7 @@ class RoborockSelectDescription( if data.water_box_mode is not None else None, parameter_lambda=lambda key, status: [status.get_mop_intensity_code(key)], + protocol_listener=RoborockDataProtocol.WATER_BOX_MODE, ), RoborockSelectDescription( key="mop_mode", @@ -105,6 +109,8 @@ def __init__( self.entity_description = entity_description super().__init__(unique_id, coordinator) 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 090ab2f233c038..e3cea00476f01b 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -11,6 +11,7 @@ RoborockErrorCode, RoborockStateCode, ) +from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import DeviceProp from homeassistant.components.sensor import ( @@ -35,19 +36,21 @@ from .device import RoborockCoordinatedEntity -@dataclass +@dataclass(frozen=True) class RoborockSensorDescriptionMixin: """A class that describes sensor entities.""" value_fn: Callable[[DeviceProp], StateType | datetime.datetime] -@dataclass +@dataclass(frozen=True) class RoborockSensorDescription( SensorEntityDescription, RoborockSensorDescriptionMixin ): """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 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: 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 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: 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 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: 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, @@ -120,6 +126,7 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: value_fn=lambda data: data.status.state_name, entity_category=EntityCategory.DIAGNOSTIC, options=RoborockStateCode.keys(), + protocol_listener=RoborockDataProtocol.STATE, ), RoborockSensorDescription( key="cleaning_area", @@ -145,6 +152,7 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: 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,6 +160,7 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + protocol_listener=RoborockDataProtocol.BATTERY, ), RoborockSensorDescription( key="last_clean_start", @@ -238,6 +247,8 @@ def __init__( """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 8841741d4a1641..67660816de7ac2 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": { diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 3dd7307da72299..37e8488dd226d5 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RoborockSwitchDescriptionMixin: """Define an entity description mixin for switch entities.""" @@ -36,7 +36,7 @@ class RoborockSwitchDescriptionMixin: attribute: str -@dataclass +@dataclass(frozen=True) class RoborockSwitchDescription( SwitchEntityDescription, RoborockSwitchDescriptionMixin ): diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index d02d63597ac999..7a8d21fc0f14bb 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RoborockTimeDescriptionMixin: """Define an entity description mixin for time entities.""" @@ -37,7 +37,7 @@ class RoborockTimeDescriptionMixin: get_value: Callable[[AttributeCache], datetime.time] -@dataclass +@dataclass(frozen=True) class RoborockTimeDescription(TimeEntityDescription, RoborockTimeDescriptionMixin): """Class to describe an Roborock time entity.""" diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 0edd8e3ec5a947..c8b43e74efd263 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 ( @@ -94,6 +95,12 @@ def __init__( StateVacuumEntity.__init__(self) RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) 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: diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index b08933dcd9134b..144fded24b9cb2 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -19,14 +19,14 @@ from .entity import RokuEntity -@dataclass +@dataclass(frozen=True) class RokuBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[RokuDevice], bool | None] -@dataclass +@dataclass(frozen=True) class RokuBinarySensorEntityDescription( BinarySensorEntityDescription, RokuBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 4173d2f5c6e3bd..acaf2e5adbc56e 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -39,7 +39,7 @@ MediaType.CHANNELS, ] -GetBrowseImageUrlType = Callable[[str, str, "str | None"], str] +GetBrowseImageUrlType = Callable[[str, str, "str | None"], str | None] def get_thumbnail_url_full( diff --git a/homeassistant/components/roku/helpers.py b/homeassistant/components/roku/helpers.py index 60392d89f1d274..60a3cbeec304e4 100644 --- a/homeassistant/components/roku/helpers.py +++ b/homeassistant/components/roku/helpers.py @@ -32,7 +32,7 @@ def roku_exception_handler( """Decorate Roku calls to handle Roku exceptions.""" def decorator( - func: _FuncType[_RokuEntityT, _P] + func: _FuncType[_RokuEntityT, _P], ) -> _ReturnFuncType[_RokuEntityT, _P]: @wraps(func) async def wrapper( diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index 430133b7f77fd2..ef0f198f586e9e 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -18,7 +18,7 @@ from .helpers import format_channel_name, roku_exception_handler -@dataclass +@dataclass(frozen=True) class RokuSelectEntityDescriptionMixin: """Mixin for required keys.""" @@ -85,7 +85,7 @@ async def _tune_channel(device: RokuDevice, roku: Roku, value: str) -> None: await roku.tune(_channel.number) -@dataclass +@dataclass(frozen=True) class RokuSelectEntityDescription( SelectEntityDescription, RokuSelectEntityDescriptionMixin ): diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index 69b8c34d3128d1..b462b8c531b271 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -17,14 +17,14 @@ from .entity import RokuEntity -@dataclass +@dataclass(frozen=True) class RokuSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[RokuDevice], str | None] -@dataclass +@dataclass(frozen=True) class RokuSensorEntityDescription( SensorEntityDescription, RokuSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 818b43930f4d34..9eef366163eef1 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/manifest.json b/homeassistant/components/roomba/manifest.json index 8e6b92732eb2e3..fbe6c9254380a1 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,7 +24,7 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.8"], + "requirements": ["roombapy==1.6.10"], "zeroconf": [ { "type": "_amzn-alexa._tcp.local.", diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 7d103111301be3..ad2894ebb117cb 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -11,7 +11,12 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.const import ( + AREA_SQUARE_METERS, + PERCENTAGE, + EntityCategory, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -21,14 +26,14 @@ from .models import RoombaData -@dataclass +@dataclass(frozen=True) class RoombaSensorEntityDescriptionMixin: """Mixin for describing Roomba data.""" value_fn: Callable[[IRobotEntity], StateType] -@dataclass +@dataclass(frozen=True) class RoombaSensorEntityDescription( SensorEntityDescription, RoombaSensorEntityDescriptionMixin ): @@ -114,6 +119,18 @@ class RoombaSensorEntityDescription( value_fn=lambda self: self.run_stats.get("nScrubs"), entity_registry_enabled_default=False, ), + RoombaSensorEntityDescription( + key="total_cleaned_area", + translation_key="total_cleaned_area", + icon="mdi:texture-box", + native_unit_of_measurement=AREA_SQUARE_METERS, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: ( + None if (sqft := self.run_stats.get("sqft")) is None else sqft * 9.29 + ), + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), ] diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index f1816d58613116..088918824d256c 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": { @@ -78,6 +84,9 @@ }, "scrubs_count": { "name": "Scrubs" + }, + "total_cleaned_area": { + "name": "Total cleaned area" } } } diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index 2598d9e8de1253..0dcb5b87581ba7 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.5"] + "requirements": ["roonapi==0.1.6"] } diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index d6128d26723589..afbf0e6b4a7b6b 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -207,13 +207,14 @@ def _parse_volume(cls, player_data): try: volume_max = volume_data["max"] volume_min = volume_data["min"] + raw_level = convert(volume_data["value"], float, 0) volume_range = volume_max - volume_min volume_percentage_factor = volume_range / 100 level = (raw_level - volume_min) / volume_percentage_factor - volume["level"] = convert(level, int, 0) / 100 + volume["level"] = round(level) / 100 except KeyError: pass @@ -373,14 +374,14 @@ def mute_volume(self, mute=True): def volume_up(self) -> None: """Send new volume_level to device.""" if self._volume_incremental: - self._server.roonapi.change_volume_raw(self.output_id, 1, "relative_step") + self._server.roonapi.change_volume_raw(self.output_id, 1, "relative") else: self._server.roonapi.change_volume_percent(self.output_id, 3) def volume_down(self) -> None: """Send new volume_level to device.""" if self._volume_incremental: - self._server.roonapi.change_volume_raw(self.output_id, -1, "relative_step") + self._server.roonapi.change_volume_raw(self.output_id, -1, "relative") else: self._server.roonapi.change_volume_percent(self.output_id, -3) diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index 769cde67d7aa8d..65a39e5e218b19 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/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 47a9bbfdde0b65..c4fbe47477606f 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -1,17 +1,13 @@ """Bluetooth support for Ruuvi Gateway.""" from __future__ import annotations -from collections.abc import Callable import logging import time -from home_assistant_bluetooth import BluetoothServiceInfoBleak - from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, BaseHaRemoteScanner, - async_get_advertisement_callback, async_register_scanner, ) from homeassistant.config_entries import ConfigEntry @@ -27,19 +23,15 @@ class RuuviGatewayScanner(BaseHaRemoteScanner): def __init__( self, - hass: HomeAssistant, scanner_id: str, name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], *, coordinator: RuuviGatewayUpdateCoordinator, ) -> None: """Initialize the scanner, using the given update coordinator as data source.""" super().__init__( - hass, scanner_id, name, - new_info_callback, connector=None, connectable=False, ) @@ -87,14 +79,12 @@ def async_connect_scanner( source, ) scanner = RuuviGatewayScanner( - hass=hass, scanner_id=source, name=entry.title, - new_info_callback=async_get_advertisement_callback(hass), coordinator=coordinator, ) unload_callbacks = [ - async_register_scanner(hass, scanner, connectable=False), + async_register_scanner(hass, scanner), scanner.async_setup(), scanner.start_polling(), ] diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index babdbc573bd14b..7d0437da03393d 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,8 +1,9 @@ """Support for monitoring an SABnzbd NZB client.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import logging +from typing import Any from pysabnzbd import SabnzbdApiException import voluptuous as vol @@ -189,7 +190,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_device_identifiers(hass, entry) @callback - def extract_api(func: Callable) -> Callable: + def extract_api( + func: Callable[[ServiceCall, SabnzbdApiData], Coroutine[Any, Any, None]], + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct api for a service call.""" async def wrapper(call: ServiceCall) -> None: diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index d4920ef77f345d..ff33c084ffa044 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -20,14 +20,14 @@ from .const import DEFAULT_NAME, KEY_API_DATA -@dataclass +@dataclass(frozen=True) class SabnzbdRequiredKeysMixin: """Mixin for required keys.""" key: str -@dataclass +@dataclass(frozen=True) class SabnzbdSensorEntityDescription(SensorEntityDescription, SabnzbdRequiredKeysMixin): """Describes Sabnzbd sensor entity.""" diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 03a9c35c9ba281..f2767ce693ec8c 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -34,6 +34,7 @@ from websockets.exceptions import ConnectionClosedError, WebSocketException from homeassistant.const import ( + CONF_DESCRIPTION, CONF_HOST, CONF_ID, CONF_METHOD, @@ -50,7 +51,6 @@ from homeassistant.util import dt as dt_util from .const import ( - CONF_DESCRIPTION, CONF_SESSION_ID, ENCRYPTED_WEBSOCKET_PORT, LEGACY_PORT, diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py index 6699d26243bb1f..6c657145d7a63b 100644 --- a/homeassistant/components/samsungtv/const.py +++ b/homeassistant/components/samsungtv/const.py @@ -11,7 +11,6 @@ VALUE_CONF_NAME = "HomeAssistant" VALUE_CONF_ID = "ha.component.samsung" -CONF_DESCRIPTION = "description" CONF_MANUFACTURER = "manufacturer" CONF_SSDP_RENDERING_CONTROL_LOCATION = "ssdp_rendering_control_location" CONF_SSDP_MAIN_TV_AGENT_LOCATION = "ssdp_main_tv_agent_location" diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 48bdb7083b42cc..2b388cf706a33d 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -35,11 +35,11 @@ "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], "requirements": [ - "getmac==0.8.2", + "getmac==0.9.4", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.36.2" + "async-upnp-client==0.38.0" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f1f237fa4fb0f5..c9d08f756d0128 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 543cefd5b9a26d..a2139529ccfff9 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 3bfea1b09e7bcb..af91b2e227e3bc 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 feaa95864d56ee..96ff32d3e857b6 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -7,8 +7,9 @@ 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/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py index 749a961a53b922..5c97a903c72976 100644 --- a/homeassistant/components/schlage/binary_sensor.py +++ b/homeassistant/components/schlage/binary_sensor.py @@ -20,7 +20,7 @@ from .entity import SchlageEntity -@dataclass +@dataclass(frozen=True) class SchlageBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -32,7 +32,7 @@ class SchlageBinarySensorEntityDescriptionMixin: value_fn: Callable[[LockData], bool] -@dataclass +@dataclass(frozen=True) class SchlageBinarySensorEntityDescription( BinarySensorEntityDescription, SchlageBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/schlage/config_flow.py b/homeassistant/components/schlage/config_flow.py index 7e09546608793a..84bc3ef8ef6818 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 @@ 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 @@ 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() + + 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) -> str: +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 2b1e8460af2bdc..3d736306d91afe 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -5,10 +5,11 @@ 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 @@ async def _async_update_data(self) -> 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 @@ def _get_lock_data(self, lock: Lock) -> LockData: 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/manifest.json b/homeassistant/components/schlage/manifest.json index 1eb7cb2ab0fb8a..72d5ad54565040 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.11.0"] + "requirements": ["pyschlage==2023.12.1"] } diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 076ed97e298d89..721d9e80286a2b 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/schlage/switch.py b/homeassistant/components/schlage/switch.py index 1a4eeb7bcc79fc..36c8fa74244521 100644 --- a/homeassistant/components/schlage/switch.py +++ b/homeassistant/components/schlage/switch.py @@ -24,7 +24,7 @@ from .entity import SchlageEntity -@dataclass +@dataclass(frozen=True) class SchlageSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -38,7 +38,7 @@ class SchlageSwitchEntityDescriptionMixin: value_fn: Callable[[Lock], bool] -@dataclass +@dataclass(frozen=True) class SchlageSwitchEntityDescription( SwitchEntityDescription, SchlageSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/scl/__init__.py b/homeassistant/components/scl/__init__.py new file mode 100644 index 00000000000000..ae3b8d58f5e28b --- /dev/null +++ b/homeassistant/components/scl/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Seattle City Light (SCL).""" diff --git a/homeassistant/components/scl/manifest.json b/homeassistant/components/scl/manifest.json new file mode 100644 index 00000000000000..11fce2c4b47a4e --- /dev/null +++ b/homeassistant/components/scl/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "scl", + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 26603603198127..708ecc14d16e78 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.12.2", "lxml==4.9.3"] + "requirements": ["beautifulsoup4==4.12.2", "lxml==4.9.4"] } diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 9192458dde4141..096c2c22918ad8 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -1,6 +1,6 @@ """Support for a ScreenLogic Binary Sensor.""" from copy import copy -from dataclasses import dataclass +import dataclasses import logging from screenlogicpy.const.common import ON_OFF @@ -22,7 +22,7 @@ from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( - ScreenlogicEntity, + ScreenLogicEntity, ScreenLogicEntityDescription, ScreenLogicPushEntity, ScreenLogicPushEntityDescription, @@ -32,14 +32,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicBinarySensorDescription( BinarySensorEntityDescription, ScreenLogicEntityDescription ): """A class that describes ScreenLogic binary sensor eneites.""" -@dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicPushBinarySensorDescription( ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription ): @@ -232,7 +232,7 @@ async def async_setup_entry( async_add_entities(entities) -class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): +class ScreenLogicBinarySensor(ScreenLogicEntity, BinarySensorEntity): """Representation of a ScreenLogic binary sensor entity.""" entity_description: ScreenLogicBinarySensorDescription @@ -261,5 +261,7 @@ def __init__( pump_index: int, ) -> None: """Initialize of the entity.""" - entity_description.data_root = (DEVICE.PUMP, pump_index) + entity_description = dataclasses.replace( + entity_description, data_root=(DEVICE.PUMP, pump_index) + ) super().__init__(coordinator, entity_description) diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 1d3f366a498f18..7cdfbba10c0ed4 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -3,7 +3,7 @@ import logging from typing import Any -from screenlogicpy.const.common import UNIT +from screenlogicpy.const.common import UNIT, ScreenLogicCommunicationError from screenlogicpy.const.data import ATTR, DEVICE, VALUE from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.heat import HEAT_MODE @@ -68,7 +68,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class ScreenLogicClimateDescription( ClimateEntityDescription, ScreenLogicPushEntityDescription ): @@ -94,6 +94,9 @@ def __init__(self, coordinator, entity_description) -> None: [HEAT_MODE.SOLAR, HEAT_MODE.SOLAR_PREFERRED] ) self._configured_heat_modes.append(HEAT_MODE.HEATER) + self._attr_preset_modes = [ + HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes + ] self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT] @@ -140,23 +143,21 @@ def preset_mode(self) -> str: return HEAT_MODE(self._last_preset).title return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).title - @property - def preset_modes(self) -> list[str]: - """All available presets.""" - return [HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes] - async def async_set_temperature(self, **kwargs: Any) -> None: """Change the setpoint of the heater.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - if not await self.gateway.async_set_heat_temp( - int(self._data_key), int(temperature) - ): + try: + await self.gateway.async_set_heat_temp( + int(self._data_key), int(temperature) + ) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_temperature {temperature} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -166,13 +167,14 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: else: mode = HEAT_MODE.parse(self.preset_mode) - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_hvac_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode.name) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -183,13 +185,14 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: if self.hvac_mode == HVACMode.OFF: return - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_preset_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode.name) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index 74f4992717152b..f16f2b9ff34e7b 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -2,8 +2,13 @@ from datetime import timedelta import logging -from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.common import ( + SL_GATEWAY_IP, + SL_GATEWAY_NAME, + SL_GATEWAY_PORT, + ScreenLogicCommunicationError, +) from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.config_entries import ConfigEntry @@ -91,7 +96,7 @@ async def _async_update_data(self) -> None: await self.gateway.async_connect(**connect_info) await self._async_update_configured_data() - except ScreenLogicError as ex: + except ScreenLogicCommunicationError as sle: if self.gateway.is_connected: await self.gateway.async_disconnect() - raise UpdateFailed(ex.msg) from ex + raise UpdateFailed(sle.msg) from sle diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py index 719cebc1ef6c22..cda1bc83f81505 100644 --- a/homeassistant/components/screenlogic/data.py +++ b/homeassistant/components/screenlogic/data.py @@ -8,7 +8,10 @@ "new_name": "Active Alert", }, "chem_calcium_harness": { - "new_key": VALUE.CALCIUM_HARNESS, + "new_key": VALUE.CALCIUM_HARDNESS, + }, + "calcium_harness": { + "new_key": VALUE.CALCIUM_HARDNESS, }, "chem_current_orp": { "new_key": VALUE.ORP_NOW, diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 3b45aa699d3322..fc2c855d68237f 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -6,7 +6,11 @@ from typing import Any from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.common import ( + ON_OFF, + ScreenLogicCommunicationError, + ScreenLogicError, +) from screenlogicpy.const.data import ATTR from screenlogicpy.const.msg import CODE @@ -24,14 +28,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ScreenLogicEntityRequiredKeyMixin: """Mixin for required ScreenLogic entity data_path.""" data_root: ScreenLogicDataPath -@dataclass +@dataclass(frozen=True) class ScreenLogicEntityDescription( EntityDescription, ScreenLogicEntityRequiredKeyMixin ): @@ -40,7 +44,7 @@ class ScreenLogicEntityDescription( enabled_lambda: Callable[..., bool] | None = None -class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): +class ScreenLogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" entity_description: ScreenLogicEntityDescription @@ -99,14 +103,14 @@ def entity_data(self) -> dict: raise HomeAssistantError(f"Data not found: {self._data_path}") from ke -@dataclass +@dataclass(frozen=True) class ScreenLogicPushEntityRequiredKeyMixin: """Mixin for required key for ScreenLogic push entities.""" subscription_code: CODE -@dataclass +@dataclass(frozen=True) class ScreenLogicPushEntityDescription( ScreenLogicEntityDescription, ScreenLogicPushEntityRequiredKeyMixin, @@ -114,7 +118,7 @@ class ScreenLogicPushEntityDescription( """Base class for a ScreenLogic push entity description.""" -class ScreenLogicPushEntity(ScreenlogicEntity): +class ScreenLogicPushEntity(ScreenLogicEntity): """Base class for all ScreenLogic push entities.""" entity_description: ScreenLogicPushEntityDescription @@ -153,8 +157,8 @@ def _handle_coordinator_update(self) -> None: self._async_data_updated() -class ScreenLogicCircuitEntity(ScreenLogicPushEntity): - """Base class for all ScreenLogic switch and light entities.""" +class ScreenLogicSwitchingEntity(ScreenLogicEntity): + """Base class for all switchable entities.""" @property def is_on(self) -> bool: @@ -163,15 +167,24 @@ def is_on(self) -> bool: async def async_turn_on(self, **kwargs: Any) -> None: """Send the ON command.""" - await self._async_set_circuit(ON_OFF.ON) + await self._async_set_state(ON_OFF.ON) async def async_turn_off(self, **kwargs: Any) -> None: """Send the OFF command.""" - await self._async_set_circuit(ON_OFF.OFF) + await self._async_set_state(ON_OFF.OFF) - async def _async_set_circuit(self, state: ON_OFF) -> None: - if not await self.gateway.async_set_circuit(self._data_key, state.value): + async def _async_set_state(self, state: ON_OFF) -> None: + raise NotImplementedError() + + +class ScreenLogicCircuitEntity(ScreenLogicSwitchingEntity, ScreenLogicPushEntity): + """Base class for all ScreenLogic circuit switch and light entities.""" + + async def _async_set_state(self, state: ON_OFF) -> None: + try: + await self.gateway.async_set_circuit(self._data_key, state.value) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: raise HomeAssistantError( - f"Failed to set_circuit {self._data_key} {state.value}" - ) + f"Failed to set_circuit {self._data_key} {state.value}: {sle.msg}" + ) from sle _LOGGER.debug("Set circuit %s %s", self._data_key, state.value) diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 80499f7790a505..60cf7d52a483dd 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -60,7 +60,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class ScreenLogicLightDescription( LightEntityDescription, ScreenLogicPushEntityDescription ): diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 69bed1af700ea2..434b8921bc2b0b 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.4"] + "requirements": ["screenlogicpy==0.10.0"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index a52e894c72baf1..1ff611b2c9fcfa 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -1,9 +1,8 @@ """Support for a ScreenLogic number entity.""" -import asyncio -from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +from screenlogicpy.const.common import ScreenLogicCommunicationError, ScreenLogicError from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from screenlogicpy.device_const.system import EQUIPMENT_FLAG @@ -15,11 +14,12 @@ 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 .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator -from .entity import ScreenlogicEntity, ScreenLogicEntityDescription +from .entity import ScreenLogicEntity, ScreenLogicEntityDescription from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) @@ -27,40 +27,21 @@ PARALLEL_UPDATES = 1 -@dataclass -class ScreenLogicNumberRequiredMixin: - """Describes a required mixin for a ScreenLogic number entity.""" - - set_value_name: str - set_value_args: tuple[tuple[str | int, ...], ...] - - -@dataclass +@dataclass(frozen=True) class ScreenLogicNumberDescription( NumberEntityDescription, ScreenLogicEntityDescription, - ScreenLogicNumberRequiredMixin, ): """Describes a ScreenLogic number entity.""" SUPPORTED_SCG_NUMBERS = [ ScreenLogicNumberDescription( - set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.POOL_SETPOINT, entity_category=EntityCategory.CONFIG, ), ScreenLogicNumberDescription( - set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.SPA_SETPOINT, entity_category=EntityCategory.CONFIG, @@ -89,13 +70,13 @@ async def async_setup_entry( cleanup_excluded_entity(coordinator, DOMAIN, scg_number_data_path) continue if gateway.get_data(*scg_number_data_path): - entities.append(ScreenLogicNumber(coordinator, scg_number_description)) + entities.append(ScreenLogicSCGNumber(coordinator, scg_number_description)) async_add_entities(entities) -class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): - """Class to represent a ScreenLogic Number entity.""" +class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): + """Base class to represent a ScreenLogic Number entity.""" entity_description: ScreenLogicNumberDescription @@ -106,14 +87,7 @@ def __init__( ) -> None: """Initialize a ScreenLogic number entity.""" super().__init__(coordinator, entity_description) - if not asyncio.iscoroutinefunction( - func := getattr(self.gateway, entity_description.set_value_name) - ): - raise TypeError( - f"set_value_name '{entity_description.set_value_name}' is not a coroutine" - ) - self._set_value_func: Callable[..., Awaitable[bool]] = func - self._set_value_args = entity_description.set_value_args + self._attr_native_unit_of_measurement = get_ha_unit( self.entity_data.get(ATTR.UNIT) ) @@ -137,22 +111,23 @@ def native_value(self) -> float: async def async_set_native_value(self, value: float) -> None: """Update the current value.""" + raise NotImplementedError() - # Current API requires certain values to be set at the same time. This - # gathers the existing values and updates the particular value being - # set by this entity. - args = {} - for data_path in self._set_value_args: - data_key = data_path[-1] - args[data_key] = self.coordinator.gateway.get_value(*data_path, strict=True) + +class ScreenLogicSCGNumber(ScreenLogicNumber): + """Class to represent a ScreenLoigic SCG Number entity.""" + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" # Current API requires int values for the currently supported numbers. value = int(value) - args[self._data_key] = value - - if await self._set_value_func(*args.values()): - _LOGGER.debug("Set '%s' to %s", self._data_key, value) - await self._async_refresh() - else: - _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value) + try: + await self.gateway.async_set_scg_config(**{self._data_key: value}) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: + raise HomeAssistantError( + f"Failed to set '{self._data_key}' to {value}: {sle.msg}" + ) from sle + _LOGGER.debug("Set '%s' to %s", self._data_key, value) + await self._async_refresh() diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index bbcf8458014577..c73ce8be42c6c3 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -1,7 +1,7 @@ """Support for a ScreenLogic Sensor.""" from collections.abc import Callable from copy import copy -from dataclasses import dataclass +import dataclasses import logging from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE @@ -25,7 +25,7 @@ from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( - ScreenlogicEntity, + ScreenLogicEntity, ScreenLogicEntityDescription, ScreenLogicPushEntity, ScreenLogicPushEntityDescription, @@ -35,21 +35,21 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicSensorMixin: """Mixin for SecreenLogic sensor entity.""" value_mod: Callable[[int | str], int | str] | None = None -@dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicSensorDescription( ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription ): """Describes a ScreenLogic sensor.""" -@dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicPushSensorDescription( ScreenLogicSensorDescription, ScreenLogicPushEntityDescription ): @@ -139,7 +139,7 @@ class ScreenLogicPushSensorDescription( ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), - key=VALUE.CALCIUM_HARNESS, + key=VALUE.CALCIUM_HARDNESS, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -272,7 +272,9 @@ async def async_setup_entry( cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) continue if gateway.get_data(*chem_sensor_data_path): - chem_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + chem_sensor_description = dataclasses.replace( + chem_sensor_description, entity_category=EntityCategory.DIAGNOSTIC + ) entities.append(ScreenLogicPushSensor(coordinator, chem_sensor_description)) scg_sensor_description: ScreenLogicSensorDescription @@ -285,13 +287,15 @@ async def async_setup_entry( cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) continue if gateway.get_data(*scg_sensor_data_path): - scg_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + scg_sensor_description = dataclasses.replace( + scg_sensor_description, entity_category=EntityCategory.DIAGNOSTIC + ) entities.append(ScreenLogicSensor(coordinator, scg_sensor_description)) async_add_entities(entities) -class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): +class ScreenLogicSensor(ScreenLogicEntity, SensorEntity): """Representation of a ScreenLogic sensor entity.""" entity_description: ScreenLogicSensorDescription @@ -336,7 +340,9 @@ def __init__( pump_type: int, ) -> None: """Initialize of the entity.""" - entity_description.data_root = (DEVICE.PUMP, pump_index) + entity_description = dataclasses.replace( + entity_description, data_root=(DEVICE.PUMP, pump_index) + ) super().__init__(coordinator, entity_description) if entity_description.enabled_lambda: self._attr_entity_registry_enabled_default = ( diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 4900ed938a1559..43f749db91349b 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -13,18 +13,29 @@ from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS from .coordinator import ScreenlogicDataUpdateCoordinator -from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription +from .entity import ( + ScreenLogicCircuitEntity, + ScreenLogicPushEntityDescription, + ScreenLogicSwitchingEntity, +) _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True) +class ScreenLogicCircuitSwitchDescription( + SwitchEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic switch entity.""" + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicSwitch] = [] + entities: list[ScreenLogicSwitchingEntity] = [] coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] @@ -39,9 +50,9 @@ async def async_setup_entry( circuit_name = circuit_data[ATTR.NAME] circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) entities.append( - ScreenLogicSwitch( + ScreenLogicCircuitSwitch( coordinator, - ScreenLogicSwitchDescription( + ScreenLogicCircuitSwitchDescription( subscription_code=CODE.STATUS_CHANGED, data_root=(DEVICE.CIRCUIT,), key=circuit_index, @@ -56,14 +67,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass -class ScreenLogicSwitchDescription( - SwitchEntityDescription, ScreenLogicPushEntityDescription -): - """Describes a ScreenLogic switch entity.""" - - -class ScreenLogicSwitch(ScreenLogicCircuitEntity, SwitchEntity): +class ScreenLogicCircuitSwitch(ScreenLogicCircuitEntity, SwitchEntity): """Class to represent a ScreenLogic Switch.""" - entity_description: ScreenLogicSwitchDescription + entity_description: ScreenLogicCircuitSwitchDescription diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index c11bb37294fe39..1cbab23d84347c 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -13,7 +13,7 @@ is_blueprint_instance_config, ) from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_DEFAULT, @@ -30,7 +30,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import ( SCRIPT_MODE_SINGLE, async_validate_actions_config, diff --git a/homeassistant/components/script/helpers.py b/homeassistant/components/script/helpers.py index 9f0d4399d3d20b..4504869e270e23 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/season/config_flow.py b/homeassistant/components/season/config_flow.py index 39a52e57b10b35..069037e53a0ced 100644 --- a/homeassistant/components/season/config_flow.py +++ b/homeassistant/components/season/config_flow.py @@ -8,6 +8,11 @@ from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_TYPE from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL @@ -33,11 +38,15 @@ async def async_step_user( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In( - { - TYPE_ASTRONOMICAL: "Astronomical", - TYPE_METEOROLOGICAL: "Meteorological", - } + vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): SelectSelector( + SelectSelectorConfig( + translation_key="season_type", + mode=SelectSelectorMode.LIST, + options=[ + TYPE_ASTRONOMICAL, + TYPE_METEOROLOGICAL, + ], + ) ) }, ), diff --git a/homeassistant/components/season/strings.json b/homeassistant/components/season/strings.json index 162daddd41223d..b0313d227a3cb9 100644 --- a/homeassistant/components/season/strings.json +++ b/homeassistant/components/season/strings.json @@ -23,5 +23,13 @@ } } } + }, + "selector": { + "season_type": { + "options": { + "astronomical": "Astronomical", + "meteorological": "Meteorological" + } + } } } diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 4997e088a54b08..8ec08f4606f42b 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -1,15 +1,15 @@ """Component to allow selecting an option from a list as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, @@ -31,6 +31,11 @@ SERVICE_SELECT_PREVIOUS, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -86,7 +91,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SELECT_OPTION, {vol.Required(ATTR_OPTION): cv.string}, - async_select_option, + SelectEntity.async_handle_select_option.__name__, ) component.async_register_entity_service( @@ -98,14 +103,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_select_option(entity: SelectEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new value.""" - option = service_call.data[ATTR_OPTION] - if option not in entity.options: - raise ValueError(f"Option {option} not valid for {entity.entity_id}") - await entity.async_select_option(option) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent[SelectEntity] = hass.data[DOMAIN] @@ -118,14 +115,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SelectEntityDescription(EntityDescription): +class SelectEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes select entities.""" options: list[str] | None = None -class SelectEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "current_option", + "options", +} + + +class SelectEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Select entity.""" _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) @@ -151,7 +153,7 @@ def state(self) -> str | None: return None return current_option - @property + @cached_property def options(self) -> list[str]: """Return a set of selectable options.""" if hasattr(self, "_attr_options"): @@ -163,11 +165,35 @@ def options(self) -> list[str]: return self.entity_description.options raise AttributeError() - @property + @cached_property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self._attr_current_option + @final + @callback + def _valid_option_or_raise(self, option: str) -> None: + """Raise ServiceValidationError on invalid option.""" + options = self.options + if not options or option not in options: + friendly_options: str = ", ".join(options or []) + raise ServiceValidationError( + f"Option {option} is not valid for {self.entity_id}", + translation_domain=DOMAIN, + translation_key="not_valid_option", + translation_placeholders={ + "entity_id": self.entity_id, + "option": option, + "options": friendly_options, + }, + ) + + @final + async def async_handle_select_option(self, option: str) -> None: + """Service call wrapper to set a new value.""" + self._valid_option_or_raise(option) + await self.async_select_option(option) + def select_option(self, option: str) -> None: """Change the selected option.""" raise NotImplementedError() diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index d058ff6e6f2c7f..9c9d1136b996c9 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -64,5 +64,10 @@ } } } + }, + "exceptions": { + "not_valid_option": { + "message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}." + } } } diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 08f45b94789eba..5cd71a2b0e41df 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -24,28 +24,28 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class MotionBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[MotionSensor], bool | None] -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[SensiboDevice], bool | None] -@dataclass +@dataclass(frozen=True) class SensiboMotionBinarySensorEntityDescription( BinarySensorEntityDescription, MotionBaseEntityDescriptionMixin ): """Describes Sensibo Motion sensor entity.""" -@dataclass +@dataclass(frozen=True) class SensiboDeviceBinarySensorEntityDescription( BinarySensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index b47023f3ec49a8..942f7eaeb00cd8 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -17,14 +17,14 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class SensiboEntityDescriptionMixin: """Mixin values for Sensibo entities.""" data_key: str -@dataclass +@dataclass(frozen=True) class SensiboButtonEntityDescription( ButtonEntityDescription, SensiboEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 40aa54e5d56ffc..89e1fafa213c7a 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -22,7 +22,7 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter @@ -314,11 +314,17 @@ async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if "targetTemperature" not in self.device_data.active_features: raise HomeAssistantError( - "Current mode doesn't support setting Target Temperature" + "Current mode doesn't support setting Target Temperature", + translation_domain=DOMAIN, + translation_key="no_target_temperature_in_features", ) if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - raise ValueError("No target temperature provided") + raise ServiceValidationError( + "No target temperature provided", + translation_domain=DOMAIN, + translation_key="no_target_temperature", + ) if temperature == self.target_temperature: return @@ -334,10 +340,17 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if "fanLevel" not in self.device_data.active_features: - raise HomeAssistantError("Current mode doesn't support setting Fanlevel") + raise HomeAssistantError( + "Current mode doesn't support setting Fanlevel", + translation_domain=DOMAIN, + translation_key="no_fan_level_in_features", + ) if fan_mode not in AVAILABLE_FAN_MODES: raise HomeAssistantError( - f"Climate fan mode {fan_mode} is not supported by the integration, please open an issue" + f"Climate fan mode {fan_mode} is not supported by the integration, please open an issue", + translation_domain=DOMAIN, + translation_key="fan_mode_not_supported", + translation_placeholders={"fan_mode": fan_mode}, ) transformation = self.device_data.fan_modes_translated @@ -379,10 +392,17 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" if "swing" not in self.device_data.active_features: - raise HomeAssistantError("Current mode doesn't support setting Swing") + raise HomeAssistantError( + "Current mode doesn't support setting Swing", + translation_domain=DOMAIN, + translation_key="no_swing_in_features", + ) if swing_mode not in AVAILABLE_SWING_MODES: raise HomeAssistantError( - f"Climate swing mode {swing_mode} is not supported by the integration, please open an issue" + f"Climate swing mode {swing_mode} is not supported by the integration, please open an issue", + translation_domain=DOMAIN, + translation_key="swing_not_supported", + translation_placeholders={"swing_mode": swing_mode}, ) transformation = self.device_data.swing_modes_translated diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 9f20c051576115..5a755a7730cc7c 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -19,26 +19,40 @@ def async_handle_api_call( - function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]] + function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]: """Decorate api calls.""" - async def wrap_api_call(*args: Any, **kwargs: Any) -> None: + async def wrap_api_call(entity: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap services for api calls.""" res: bool = False + if TYPE_CHECKING: + assert isinstance(entity.name, str) try: async with asyncio.timeout(TIMEOUT): - res = await function(*args, **kwargs) + res = await function(entity, *args, **kwargs) except SENSIBO_ERRORS as err: - raise HomeAssistantError from err - - LOGGER.debug("Result %s for entity %s with arguments %s", res, args[0], kwargs) - entity: SensiboDeviceBaseEntity = args[0] + raise HomeAssistantError( + str(err), + translation_domain=DOMAIN, + translation_key="service_raised", + translation_placeholders={"error": str(err), "name": entity.name}, + ) from err + + LOGGER.debug("Result %s for entity %s with arguments %s", res, entity, kwargs) if res is not True: - raise HomeAssistantError(f"Could not execute service for {entity.name}") - if kwargs.get("key") is not None and kwargs.get("value") is not None: - setattr(entity.device_data, kwargs["key"], kwargs["value"]) - LOGGER.debug("Debug check key %s is now %s", kwargs["key"], kwargs["value"]) + raise HomeAssistantError( + f"Could not execute service for {entity.name}", + translation_domain=DOMAIN, + translation_key="service_result_not_true", + translation_placeholders={"name": entity.name}, + ) + if ( + isinstance(key := kwargs.get("key"), str) + and (value := kwargs.get("value")) is not None + ): + setattr(entity.device_data, key, value) + LOGGER.debug("Debug check key %s is now %s", key, value) entity.async_write_ha_state() await entity.coordinator.async_request_refresh() diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index d4e268ea44d4d3..ac76277fb205fc 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -24,7 +24,7 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class SensiboEntityDescriptionMixin: """Mixin values for Sensibo entities.""" @@ -32,7 +32,7 @@ class SensiboEntityDescriptionMixin: value_fn: Callable[[SensiboDevice], float | None] -@dataclass +@dataclass(frozen=True) class SensiboNumberEntityDescription( NumberEntityDescription, SensiboEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index cda8a972ede14c..bbac3fbdbd030a 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -20,7 +20,7 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class SensiboSelectDescriptionMixin: """Mixin values for Sensibo entities.""" @@ -30,7 +30,7 @@ class SensiboSelectDescriptionMixin: transformation: Callable[[SensiboDevice], dict | None] -@dataclass +@dataclass(frozen=True) class SensiboSelectEntityDescription( SelectEntityDescription, SensiboSelectDescriptionMixin ): @@ -106,9 +106,16 @@ def options(self) -> list[str]: async def async_select_option(self, option: str) -> None: """Set state to the selected option.""" if self.entity_description.key not in self.device_data.active_features: + hvac_mode = self.device_data.hvac_mode if self.device_data.hvac_mode else "" raise HomeAssistantError( f"Current mode {self.device_data.hvac_mode} doesn't support setting" - f" {self.entity_description.name}" + f" {self.entity_description.name}", + translation_domain=DOMAIN, + translation_key="select_option_not_available", + translation_placeholders={ + "hvac_mode": hvac_mode, + "key": self.entity_description.key, + }, ) await self.async_send_api_call( diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index f6d62d79dff3ce..805b888204b1cd 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -36,14 +36,14 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class MotionBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[MotionSensor], StateType] -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" @@ -51,14 +51,14 @@ class DeviceBaseEntityDescriptionMixin: extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None] | None] | None -@dataclass +@dataclass(frozen=True) class SensiboMotionSensorEntityDescription( SensorEntityDescription, MotionBaseEntityDescriptionMixin ): """Describes Sensibo Motion sensor entity.""" -@dataclass +@dataclass(frozen=True) class SensiboDeviceSensorEntityDescription( SensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 6081c668d898f8..a5f71e53c17f4c 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -478,5 +478,37 @@ } } } + }, + "exceptions": { + "no_target_temperature_in_features": { + "message": "Current mode doesn't support setting target temperature" + }, + "no_target_temperature": { + "message": "No target temperature provided" + }, + "no_fan_level_in_features": { + "message": "Current mode doesn't support setting fan level" + }, + "fan_mode_not_supported": { + "message": "Climate fan mode {fan_mode} is not supported by the integration, please open an issue" + }, + "no_swing_in_features": { + "message": "Current mode doesn't support setting swing" + }, + "swing_not_supported": { + "message": "Climate swing mode {swing_mode} is not supported by the integration, please open an issue" + }, + "service_result_not_true": { + "message": "Could not execute service for {name}" + }, + "service_raised": { + "message": "Could not execute service for {name} with error {error}" + }, + "select_option_not_available": { + "message": "Current mode {hvac_mode} doesn't support setting {key}" + }, + "climate_react_not_available": { + "message": "Use Sensibo Enable Climate React Service once to enable switch or the Sensibo app" + } } } diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 204ed622f13e11..0911985ed7d626 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -24,7 +24,7 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo Device description keys.""" @@ -35,7 +35,7 @@ class DeviceBaseEntityDescriptionMixin: data_key: str -@dataclass +@dataclass(frozen=True) class SensiboDeviceSwitchEntityDescription( SwitchEntityDescription, DeviceBaseEntityDescriptionMixin ): @@ -184,7 +184,9 @@ async def async_turn_on_off_smart(self, key: str, value: bool) -> bool: if self.device_data.smart_type is None: raise HomeAssistantError( "Use Sensibo Enable Climate React Service once to enable switch or the" - " Sensibo app" + " Sensibo app", + translation_domain=DOMAIN, + translation_key="climate_react_not_available", ) data: dict[str, Any] = {"enabled": value} result = await self._client.async_enable_climate_react(self._device_id, data) diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 62e8bbff3ae76d..c51d57dd9d192f 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -23,7 +23,7 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" @@ -31,7 +31,7 @@ class DeviceBaseEntityDescriptionMixin: value_available: Callable[[SensiboDevice], str | None] -@dataclass +@dataclass(frozen=True) class SensiboDeviceUpdateEntityDescription( UpdateEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 0fa270bb03dd28..6077e4708d5212 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -7,9 +7,10 @@ from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta from decimal import Decimal, InvalidOperation as DecimalInvalidOperation +from functools import partial import logging from math import ceil, floor, isfinite, log10 -from typing import Any, Final, Self, cast, final +from typing import TYPE_CHECKING, Any, Final, Self, cast, final from typing_extensions import override @@ -17,36 +18,36 @@ # pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 + _DEPRECATED_DEVICE_CLASS_AQI, + _DEPRECATED_DEVICE_CLASS_BATTERY, + _DEPRECATED_DEVICE_CLASS_CO, + _DEPRECATED_DEVICE_CLASS_CO2, + _DEPRECATED_DEVICE_CLASS_CURRENT, + _DEPRECATED_DEVICE_CLASS_DATE, + _DEPRECATED_DEVICE_CLASS_ENERGY, + _DEPRECATED_DEVICE_CLASS_FREQUENCY, + _DEPRECATED_DEVICE_CLASS_GAS, + _DEPRECATED_DEVICE_CLASS_HUMIDITY, + _DEPRECATED_DEVICE_CLASS_ILLUMINANCE, + _DEPRECATED_DEVICE_CLASS_MONETARY, + _DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE, + _DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE, + _DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE, + _DEPRECATED_DEVICE_CLASS_OZONE, + _DEPRECATED_DEVICE_CLASS_PM1, + _DEPRECATED_DEVICE_CLASS_PM10, + _DEPRECATED_DEVICE_CLASS_PM25, + _DEPRECATED_DEVICE_CLASS_POWER, + _DEPRECATED_DEVICE_CLASS_POWER_FACTOR, + _DEPRECATED_DEVICE_CLASS_PRESSURE, + _DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH, + _DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE, + _DEPRECATED_DEVICE_CLASS_TEMPERATURE, + _DEPRECATED_DEVICE_CLASS_TIMESTAMP, + _DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + _DEPRECATED_DEVICE_CLASS_VOLTAGE, ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_AQI, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_DATE, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_FREQUENCY, - DEVICE_CLASS_GAS, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_MONETARY, - DEVICE_CLASS_NITROGEN_DIOXIDE, - DEVICE_CLASS_NITROGEN_MONOXIDE, - DEVICE_CLASS_NITROUS_OXIDE, - DEVICE_CLASS_OZONE, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM10, - DEVICE_CLASS_PM25, - DEVICE_CLASS_POWER, - DEVICE_CLASS_POWER_FACTOR, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_SULPHUR_DIOXIDE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, - DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - DEVICE_CLASS_VOLTAGE, EntityCategory, UnitOfTemperature, ) @@ -57,6 +58,10 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform @@ -66,6 +71,9 @@ from homeassistant.util.enum import try_parse_enum from .const import ( # noqa: F401 + _DEPRECATED_STATE_CLASS_MEASUREMENT, + _DEPRECATED_STATE_CLASS_TOTAL, + _DEPRECATED_STATE_CLASS_TOTAL_INCREASING, ATTR_LAST_RESET, ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -76,9 +84,6 @@ DEVICE_CLASSES_SCHEMA, DOMAIN, NON_NUMERIC_DEVICE_CLASSES, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL, - STATE_CLASS_TOTAL_INCREASING, STATE_CLASSES, STATE_CLASSES_SCHEMA, UNIT_CONVERTERS, @@ -87,6 +92,11 @@ ) from .websocket_api import async_setup as async_setup_ws_api +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER: Final = logging.getLogger(__name__) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" @@ -110,6 +120,12 @@ "SensorStateClass", ] +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + # mypy: disallow-any-generics @@ -136,8 +152,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SensorEntityDescription(EntityDescription): +class SensorEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes sensor entities.""" device_class: SensorDeviceClass | None = None @@ -172,7 +187,21 @@ def _numeric_state_expected( return device_class is not None -class SensorEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "last_reset", + "native_unit_of_measurement", + "native_value", + "options", + "state_class", + "suggested_display_precision", + "suggested_unit_of_measurement", +} + +TEMPERATURE_UNITS = {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT} + + +class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for sensor entities.""" _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) @@ -292,7 +321,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class not in (None, SensorDeviceClass.ENUM) - @property + @cached_property @override def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" @@ -313,7 +342,7 @@ def _numeric_state_expected(self) -> bool: self.suggested_display_precision, ) - @property + @cached_property def options(self) -> list[str] | None: """Return a set of possible options.""" if hasattr(self, "_attr_options"): @@ -322,7 +351,7 @@ def options(self) -> list[str] | None: return self.entity_description.options return None - @property + @cached_property def state_class(self) -> SensorStateClass | str | None: """Return the state class of this entity, if any.""" if hasattr(self, "_attr_state_class"): @@ -331,7 +360,7 @@ def state_class(self) -> SensorStateClass | str | None: return self.entity_description.state_class return None - @property + @cached_property def last_reset(self) -> datetime | None: """Return the time when the sensor was last reset, if any.""" if hasattr(self, "_attr_last_reset"): @@ -414,12 +443,12 @@ def state_attributes(self) -> dict[str, Any] | None: return None - @property + @cached_property def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" return self._attr_native_value - @property + @cached_property def suggested_display_precision(self) -> int | None: """Return the suggested number of decimal digits for display.""" if hasattr(self, "_attr_suggested_display_precision"): @@ -428,7 +457,7 @@ def suggested_display_precision(self) -> int | None: return self.entity_description.suggested_display_precision return None - @property + @cached_property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor, if any.""" if hasattr(self, "_attr_native_unit_of_measurement"): @@ -437,7 +466,7 @@ def native_unit_of_measurement(self) -> str | None: return self.entity_description.native_unit_of_measurement return None - @property + @cached_property def suggested_unit_of_measurement(self) -> str | None: """Return the unit which should be used for the sensor's state. @@ -482,9 +511,8 @@ def unit_of_measurement(self) -> str | None: native_unit_of_measurement = self.native_unit_of_measurement if ( - native_unit_of_measurement - in {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT} - and self.device_class == SensorDeviceClass.TEMPERATURE + native_unit_of_measurement in TEMPERATURE_UNITS + and self.device_class is SensorDeviceClass.TEMPERATURE ): return self.hass.config.units.temperature_unit @@ -545,7 +573,7 @@ def state(self) -> Any: return None # Received a datetime - if device_class == SensorDeviceClass.TIMESTAMP: + if device_class is SensorDeviceClass.TIMESTAMP: try: # We cast the value, to avoid using isinstance, but satisfy # typechecking. The errors are guarded in this try. @@ -567,7 +595,7 @@ def state(self) -> Any: ) from err # Received a date value - if device_class == SensorDeviceClass.DATE: + if device_class is SensorDeviceClass.DATE: try: # We cast the value, to avoid using isinstance, but satisfy # typechecking. The errors are guarded in this try. @@ -582,8 +610,8 @@ def state(self) -> Any: # Enum checks if ( options := self.options - ) is not None or device_class == SensorDeviceClass.ENUM: - if device_class != SensorDeviceClass.ENUM: + ) is not None or device_class is SensorDeviceClass.ENUM: + if device_class is not SensorDeviceClass.ENUM: reason = "is missing the enum device class" if device_class is not None: reason = f"has device class '{device_class}' instead of 'enum'" @@ -643,11 +671,10 @@ def state(self) -> Any: converter := UNIT_CONVERTERS.get(device_class) ): # Unit conversion needed - converted_numerical_value = converter.convert( - float(numerical_value), + converted_numerical_value = converter.converter_factory( native_unit_of_measurement, unit_of_measurement, - ) + )(float(numerical_value)) # If unit conversion is happening, and there's no rounding for display, # do a best effort rounding here. @@ -706,17 +733,6 @@ def state(self) -> Any: return value - def __repr__(self) -> str: - """Return the representation. - - Entity.__repr__ includes the state in the generated string, this fails if we're - called before self.hass is set. - """ - if not self.hass: - return f"" - - return super().__repr__() - def _suggested_precision_or_none(self) -> int | None: """Return suggested display precision, or None if not set.""" assert self.registry_entry diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index e8b1742f31593a..d57a09981efba7 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import StrEnum +from functools import partial from typing import Final import voluptuous as vol @@ -35,6 +36,11 @@ UnitOfVolume, UnitOfVolumetricFlux, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, @@ -451,11 +457,21 @@ class SensorStateClass(StrEnum): # STATE_CLASS* is deprecated as of 2021.12 # use the SensorStateClass enum instead. -STATE_CLASS_MEASUREMENT: Final = "measurement" -STATE_CLASS_TOTAL: Final = "total" -STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" +_DEPRECATED_STATE_CLASS_MEASUREMENT: Final = DeprecatedConstantEnum( + SensorStateClass.MEASUREMENT, "2025.1" +) +_DEPRECATED_STATE_CLASS_TOTAL: Final = DeprecatedConstantEnum( + SensorStateClass.TOTAL, "2025.1" +) +_DEPRECATED_STATE_CLASS_TOTAL_INCREASING: Final = DeprecatedConstantEnum( + SensorStateClass.TOTAL_INCREASING, "2025.1" +) STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py index 1a4dc65f010d98..f426674c32dab3 100644 --- a/homeassistant/components/sensor/significant_change.py +++ b/homeassistant/components/sensor/significant_change.py @@ -12,6 +12,7 @@ from homeassistant.helpers.significant_change import ( check_absolute_change, check_percentage_change, + check_valid_float, ) from . import SensorDeviceClass @@ -63,23 +64,20 @@ def async_check_significant_change( absolute_change = 1.0 percentage_change = 2.0 - try: + if not check_valid_float(new_state): # New state is invalid, don't report it - new_state_f = float(new_state) - except ValueError: return False - try: + if not check_valid_float(old_state): # Old state was invalid, we should report again - old_state_f = float(old_state) - except ValueError: return True if absolute_change is not None and percentage_change is not None: return _absolute_and_relative_change( - old_state_f, new_state_f, absolute_change, percentage_change + float(old_state), float(new_state), absolute_change, percentage_change ) if absolute_change is not None: - return check_absolute_change(old_state_f, new_state_f, absolute_change) - + return check_absolute_change( + float(old_state), float(new_state), absolute_change + ) return None diff --git a/homeassistant/components/sensorpush/manifest.json b/homeassistant/components/sensorpush/manifest.json index 708d9db03eed10..2c6d929a3e437e 100644 --- a/homeassistant/components/sensorpush/manifest.json +++ b/homeassistant/components/sensorpush/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpush", "iot_class": "local_push", - "requirements": ["sensorpush-ble==1.5.5"] + "requirements": ["sensorpush-ble==1.6.1"] } diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 3828a868649aeb..2af110564e7109 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.34.0"] + "requirements": ["sentry-sdk==1.37.1"] } diff --git a/homeassistant/components/senz/strings.json b/homeassistant/components/senz/strings.json index 693cfe3415b675..cb1f056d72d7a5 100644 --- a/homeassistant/components/senz/strings.json +++ b/homeassistant/components/senz/strings.json @@ -13,8 +13,8 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "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]" + "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/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 79533576efb9d6..9bf053a3897817 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -26,14 +26,14 @@ _T = TypeVar("_T") -@dataclass +@dataclass(frozen=True) class SFRBoxBinarySensorMixin(Generic[_T]): """Mixin for SFR Box sensors.""" value_fn: Callable[[_T], bool | None] -@dataclass +@dataclass(frozen=True) class SFRBoxBinarySensorEntityDescription( BinarySensorEntityDescription, SFRBoxBinarySensorMixin[_T] ): diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index c9418bcc2e9c4e..56c5335e9082eb 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -30,7 +30,7 @@ def with_error_wrapping( - func: Callable[Concatenate[SFRBoxButton, _P], Awaitable[_T]] + func: Callable[Concatenate[SFRBoxButton, _P], Awaitable[_T]], ) -> Callable[Concatenate[SFRBoxButton, _P], Coroutine[Any, Any, _T]]: """Catch SFR errors.""" @@ -49,14 +49,14 @@ async def wrapper( return wrapper -@dataclass +@dataclass(frozen=True) class SFRBoxButtonMixin: """Mixin for SFR Box buttons.""" async_press: Callable[[SFRBox], Coroutine[None, None, None]] -@dataclass +@dataclass(frozen=True) class SFRBoxButtonEntityDescription(ButtonEntityDescription, SFRBoxButtonMixin): """Description for SFR Box buttons.""" diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index 1fb98053267937..e0e84a7ec1a204 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 eb3c9cb1b68c94..bf4d91a50f154d 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 1c4540b1c74c3a..6f77ca8d2855bf 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -32,14 +32,14 @@ _T = TypeVar("_T") -@dataclass +@dataclass(frozen=True) class SFRBoxSensorMixin(Generic[_T]): """Mixin for SFR Box sensors.""" value_fn: Callable[[_T], StateType] -@dataclass +@dataclass(frozen=True) class SFRBoxSensorEntityDescription(SensorEntityDescription, SFRBoxSensorMixin[_T]): """Description for SFR Box sensors.""" @@ -188,7 +188,7 @@ class SFRBoxSensorEntityDescription(SensorEntityDescription, SFRBoxSensorMixin[_ 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 7ea18304164271..6f0001e97ced8a 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 5efc5c849d75fb..6b7a00db8e24a2 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -6,6 +6,7 @@ from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.common import ConnectionOptions +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -49,7 +50,6 @@ get_block_device_sleep_period, get_coap_context, get_device_entry_gen, - get_rpc_device_sleep_period, get_rpc_device_wakeup_period, get_ws_context, ) @@ -63,6 +63,7 @@ Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, + Platform.VALVE, ] BLOCK_SLEEPING_PLATFORMS: Final = [ Platform.BINARY_SENSOR, @@ -73,6 +74,7 @@ RPC_PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.EVENT, Platform.LIGHT, @@ -123,7 +125,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: get_entry_data(hass)[entry.entry_id] = ShellyEntryData() - if get_device_entry_gen(entry) == 2: + if get_device_entry_gen(entry) in RPC_GENERATIONS: return await _async_setup_rpc_entry(hass, entry) return await _async_setup_block_entry(hass, entry) @@ -265,9 +267,7 @@ def _async_device_online(_: Any, update_type: RpcUpdateType) -> None: if sleep_period is None: data = {**entry.data} - data[CONF_SLEEP_PERIOD] = get_rpc_device_sleep_period( - device.config - ) or get_rpc_device_wakeup_period(device.status) + data[CONF_SLEEP_PERIOD] = get_rpc_device_wakeup_period(device.status) hass.config_entries.async_update_entry(entry, data=data) hass.async_create_task(_async_rpc_device_setup()) @@ -315,7 +315,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not entry.data.get(CONF_SLEEP_PERIOD): platforms = RPC_PLATFORMS - if get_device_entry_gen(entry) == 2: + if get_device_entry_gen(entry) in RPC_GENERATIONS: if unload_ok := await hass.config_entries.async_unload_platforms( entry, platforms ): diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index a5889cd11a75c8..b07747f298e258 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from typing import Final, cast +from aioshelly.const import RPC_GENERATIONS + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -37,19 +39,19 @@ ) -@dataclass +@dataclass(frozen=True) class BlockBinarySensorDescription( BlockEntityDescription, BinarySensorEntityDescription ): """Class to describe a BLOCK binary sensor.""" -@dataclass +@dataclass(frozen=True) class RpcBinarySensorDescription(RpcEntityDescription, BinarySensorEntityDescription): """Class to describe a RPC binary sensor.""" -@dataclass +@dataclass(frozen=True) class RestBinarySensorDescription(RestEntityDescription, BinarySensorEntityDescription): """Class to describe a REST binary sensor.""" @@ -224,7 +226,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_rpc( hass, diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 429fae1a9a1939..2f9019ba5e6cfd 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -14,7 +14,6 @@ from homeassistant.components.bluetooth import ( HaBluetoothConnector, - async_get_advertisement_callback, async_register_scanner, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback @@ -36,18 +35,15 @@ async def async_connect_scanner( device = coordinator.device entry = coordinator.entry source = format_mac(coordinator.mac).upper() - new_info_callback = async_get_advertisement_callback(hass) connector = HaBluetoothConnector( # no active connections to shelly yet client=None, # type: ignore[arg-type] source=source, can_connect=lambda: False, ) - scanner = ShellyBLEScanner( - hass, source, entry.title, new_info_callback, connector, False - ) + scanner = ShellyBLEScanner(source, entry.title, connector, False) unload_callbacks = [ - async_register_scanner(hass, scanner, False), + async_register_scanner(hass, scanner), scanner.async_setup(), coordinator.async_subscribe_events(scanner.async_on_event), ] diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index edc33c9a8a05d6..17f60f566aa8d6 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -5,6 +5,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar +from aioshelly.const import RPC_GENERATIONS + from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, @@ -28,14 +30,14 @@ ) -@dataclass +@dataclass(frozen=True) class ShellyButtonDescriptionMixin(Generic[_ShellyCoordinatorT]): """Mixin to describe a Button entity.""" press_action: Callable[[_ShellyCoordinatorT], Coroutine[Any, Any, None]] -@dataclass +@dataclass(frozen=True) class ShellyButtonDescription( ButtonEntityDescription, ShellyButtonDescriptionMixin[_ShellyCoordinatorT] ): @@ -126,7 +128,7 @@ def _async_migrate_unique_ids( return async_migrate_unique_ids(entity_entry, coordinator) coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: coordinator = get_entry_data(hass)[config_entry.entry_id].rpc else: coordinator = get_entry_data(hass)[config_entry.entry_id].block diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 35c185118606ca..7cc0027bbaf212 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -6,6 +6,7 @@ from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -37,9 +38,12 @@ 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 +52,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up climate device.""" + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: + 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 +112,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 +415,73 @@ def _handle_coordinator_update(self) -> None: 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_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 = [HVACMode.OFF, HVACMode.COOL] + else: + self._attr_hvac_modes = [HVACMode.OFF, 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 bad13fde006b02..29daf05016370e 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -6,13 +6,13 @@ from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info +from aioshelly.const import BLOCK_GENERATIONS, RPC_GENERATIONS from aioshelly.exceptions import ( DeviceConnectionError, FirmwareUnsupported, InvalidAuthError, ) from aioshelly.rpc_device import RpcDevice -from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -24,21 +24,21 @@ from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .const import ( - BLE_MIN_VERSION, CONF_BLE_SCANNER_MODE, + CONF_GEN, CONF_SLEEP_PERIOD, DOMAIN, LOGGER, + MODEL_WALL_DISPLAY, BLEScannerMode, ) -from .coordinator import async_reconnect_soon, get_entry_data +from .coordinator import async_reconnect_soon from .utils import ( get_block_device_sleep_period, get_coap_context, get_info_auth, get_info_gen, get_model_name, - get_rpc_device_sleep_period, get_rpc_device_wakeup_period, get_ws_context, mac_address_from_name, @@ -68,7 +68,9 @@ async def validate_input( """ options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) - if get_info_gen(info) == 2: + gen = get_info_gen(info) + + if gen in RPC_GENERATIONS: ws_context = await get_ws_context(hass) rpc_device = await RpcDevice.create( async_get_clientsession(hass), @@ -77,15 +79,13 @@ async def validate_input( ) await rpc_device.shutdown() - sleep_period = get_rpc_device_sleep_period( - rpc_device.config - ) or get_rpc_device_wakeup_period(rpc_device.status) + sleep_period = get_rpc_device_wakeup_period(rpc_device.status) return { "title": rpc_device.name, CONF_SLEEP_PERIOD: sleep_period, "model": rpc_device.shelly.get("model"), - "gen": 2, + CONF_GEN: gen, } # Gen1 @@ -100,7 +100,7 @@ async def validate_input( "title": block_device.name, CONF_SLEEP_PERIOD: get_block_device_sleep_period(block_device.settings), "model": block_device.model, - "gen": 1, + CONF_GEN: gen, } @@ -154,7 +154,7 @@ async def async_step_user( **user_input, CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], "model": device_info["model"], - "gen": device_info["gen"], + CONF_GEN: device_info[CONF_GEN], }, ) errors["base"] = "firmware_not_fully_provisioned" @@ -169,7 +169,7 @@ async def async_step_credentials( """Handle the credentials step.""" errors: dict[str, str] = {} if user_input is not None: - if get_info_gen(self.info) == 2: + if get_info_gen(self.info) in RPC_GENERATIONS: user_input[CONF_USERNAME] = "admin" try: device_info = await validate_input( @@ -191,14 +191,14 @@ async def async_step_credentials( CONF_HOST: self.host, CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], "model": device_info["model"], - "gen": device_info["gen"], + CONF_GEN: device_info[CONF_GEN], }, ) errors["base"] = "firmware_not_fully_provisioned" else: user_input = {} - if get_info_gen(self.info) == 2: + if get_info_gen(self.info) in RPC_GENERATIONS: schema = { vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)): str, } @@ -289,7 +289,7 @@ async def async_step_confirm_discovery( "host": self.host, CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD], "model": self.device_info["model"], - "gen": self.device_info["gen"], + CONF_GEN: self.device_info[CONF_GEN], }, ) self._set_confirm_only() @@ -322,7 +322,7 @@ async def async_step_reauth_confirm( except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") - if self.entry.data.get("gen", 1) != 1: + if self.entry.data.get(CONF_GEN, 1) != 1: user_input[CONF_USERNAME] = "admin" try: await validate_input(self.hass, host, info, user_input) @@ -335,7 +335,7 @@ async def async_step_reauth_confirm( await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") - if self.entry.data.get("gen", 1) == 1: + if self.entry.data.get(CONF_GEN, 1) in BLOCK_GENERATIONS: schema = { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -363,8 +363,10 @@ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: @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(CONF_GEN) in RPC_GENERATIONS + and not config_entry.data.get(CONF_SLEEP_PERIOD) + and config_entry.data.get("model") != MODEL_WALL_DISPLAY ) @@ -380,15 +382,6 @@ async def async_step_init( ) -> FlowResult: """Handle options flow.""" if user_input is not None: - entry_data = get_entry_data(self.hass)[self.config_entry.entry_id] - if user_input[CONF_BLE_SCANNER_MODE] != BLEScannerMode.DISABLED and ( - not entry_data.rpc - or AwesomeVersion(entry_data.rpc.device.version) < BLE_MIN_VERSION - ): - return self.async_abort( - reason="ble_unsupported", - description_placeholders={"ble_min_version": BLE_MIN_VERSION}, - ) return self.async_create_entry(title="", data=user_input) return self.async_show_form( diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 0275b8052085b3..1e2c22691fb6c9 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -6,7 +6,22 @@ import re from typing import Final -from awesomeversion import AwesomeVersion +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, +) DOMAIN: Final = "shelly" @@ -17,36 +32,33 @@ DEFAULT_COAP_PORT: Final = 5683 FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})") -# Firmware 1.11.0 release date, this firmware supports light transition -LIGHT_TRANSITION_MIN_FIRMWARE_DATE: Final = 20210226 - # max light transition time in milliseconds 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 +91,11 @@ } # 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 +140,7 @@ "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 +165,11 @@ "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,9 +181,7 @@ # Time to wait before reloading entry upon device config change ENTRY_RELOAD_COOLDOWN = 60 -SHELLY_GAS_MODELS = ["SHGS-1"] - -BLE_MIN_VERSION = AwesomeVersion("0.12.0-beta2") +SHELLY_GAS_MODELS = [MODEL_GAS] CONF_BLE_SCANNER_MODE = "ble_scanner_mode" @@ -186,3 +205,14 @@ class BLEScannerMode(StrEnum): 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, +) + +CONF_GEN = "gen" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index e648a80420a4ef..77fa0bd2efd166 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -10,9 +10,9 @@ 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 from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -32,8 +32,8 @@ ATTR_DEVICE, ATTR_GENERATION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, - BLE_MIN_VERSION, CONF_BLE_SCANNER_MODE, + CONF_GEN, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, DOMAIN, @@ -136,7 +136,7 @@ def async_setup(self) -> None: manufacturer="Shelly", model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), sw_version=self.sw_version, - hw_version=f"gen{self.device.gen} ({self.model})", + hw_version=f"gen{self.entry.data[CONF_GEN]} ({self.model})", configuration_url=f"http://{self.entry.data[CONF_HOST]}", ) self.device_id = device_entry.id @@ -219,7 +219,7 @@ def _async_device_updates_handler(self) -> None: # 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,17 +583,9 @@ async def _async_connect_ble_scanner(self) -> None: 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: - LOGGER.error( - "BLE not supported on device %s with firmware %s; upgrade to %s", - self.name, - self.device.version, - BLE_MIN_VERSION, - ) - return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting # the scanner since it will be disconnected anyway diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 95f387f8f97875..4390790c7942b6 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -4,6 +4,7 @@ from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import RPC_GENERATIONS from homeassistant.components.cover import ( ATTR_POSITION, @@ -26,7 +27,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up covers for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 368a997c62e02e..796402c8bbad86 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -266,7 +266,7 @@ def async_setup_entry_rest( ) -@dataclass +@dataclass(frozen=True) class BlockEntityDescription(EntityDescription): """Class to describe a BLOCK entity.""" @@ -283,14 +283,14 @@ class BlockEntityDescription(EntityDescription): extra_state_attributes: Callable[[Block], dict | None] | None = None -@dataclass +@dataclass(frozen=True) class RpcEntityRequiredKeysMixin: """Class for RPC entity required keys.""" sub_key: str -@dataclass +@dataclass(frozen=True) class RpcEntityDescription(EntityDescription, RpcEntityRequiredKeysMixin): """Class to describe a RPC entity.""" @@ -306,7 +306,7 @@ class RpcEntityDescription(EntityDescription, RpcEntityRequiredKeysMixin): supported: Callable = lambda _: False -@dataclass +@dataclass(frozen=True) class RestEntityDescription(EntityDescription): """Class to describe a REST entity.""" diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 1b5cf911e85875..5425f71366fba4 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Final from aioshelly.block_device import Block +from aioshelly.const import MODEL_I3, RPC_GENERATIONS from homeassistant.components.event import ( DOMAIN as EVENT_DOMAIN, @@ -36,14 +37,14 @@ ) -@dataclass +@dataclass(frozen=True) class ShellyBlockEventDescription(EventEntityDescription): """Class to describe Shelly event.""" removal_condition: Callable[[dict, Block], bool] | None = None -@dataclass +@dataclass(frozen=True) class ShellyRpcEventDescription(EventEntityDescription): """Class to describe Shelly event.""" @@ -79,7 +80,7 @@ async def async_setup_entry( coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: coordinator = get_entry_data(hass)[config_entry.entry_id].rpc if TYPE_CHECKING: assert coordinator @@ -135,7 +136,7 @@ def __init__( 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 1c3a85f2f5ec87..7e49dc78e4d3e3 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -4,6 +4,7 @@ from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import MODEL_BULB, RPC_GENERATIONS from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -23,11 +24,9 @@ from .const import ( DUAL_MODE_LIGHT_MODELS, - FIRMWARE_PATTERN, KELVIN_MAX_VALUE, KELVIN_MIN_VALUE_COLOR, KELVIN_MIN_VALUE_WHITE, - LIGHT_TRANSITION_MIN_FIRMWARE_DATE, LOGGER, MAX_TRANSITION_TIME, MODELS_SUPPORTING_LIGHT_TRANSITION, @@ -54,7 +53,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up lights for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities) @@ -154,12 +153,7 @@ def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: self._attr_supported_features |= LightEntityFeature.EFFECT if coordinator.model in MODELS_SUPPORTING_LIGHT_TRANSITION: - match = FIRMWARE_PATTERN.search(coordinator.device.settings.get("fw", "")) - if ( - match is not None - and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE - ): - self._attr_supported_features |= LightEntityFeature.TRANSITION + self._attr_supported_features |= LightEntityFeature.TRANSITION @property def is_on(self) -> bool: @@ -254,7 +248,7 @@ def color_temp_kelvin(self) -> int: @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 +261,7 @@ def effect(self) -> str | None: 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 +320,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 c76e2102fa1599..b56ce07bc30764 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==7.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index f2b6bedb443a1f..77d066a6106817 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -30,7 +30,7 @@ ) -@dataclass +@dataclass(frozen=True) class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): """Class to describe a BLOCK sensor.""" diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 99ccd9ab2ff289..89dc10f0530262 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -6,6 +6,7 @@ from typing import Final, cast from aioshelly.block_device import Block +from aioshelly.const import RPC_GENERATIONS from homeassistant.components.sensor import ( RestoreSensor, @@ -54,17 +55,17 @@ from .utils import get_device_entry_gen, get_device_uptime -@dataclass +@dataclass(frozen=True) class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription): """Class to describe a BLOCK sensor.""" -@dataclass +@dataclass(frozen=True) class RpcSensorDescription(RpcEntityDescription, SensorEntityDescription): """Class to describe a RPC sensor.""" -@dataclass +@dataclass(frozen=True) class RestSensorDescription(RestEntityDescription, SensorEntityDescription): """Class to describe a REST sensor.""" @@ -371,6 +372,14 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), + "power_light": RpcSensorDescription( + key="light", + sub_key="apower", + name="Power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "power_pm1": RpcSensorDescription( key="pm1", sub_key="apower", @@ -501,6 +510,17 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "voltage_light": RpcSensorDescription( + key="light", + sub_key="voltage", + name="Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "voltage_pm1": RpcSensorDescription( key="pm1", sub_key="voltage", @@ -559,6 +579,16 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "current_light": RpcSensorDescription( + key="light", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value=lambda status, _: None if status is None else float(status), + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "current_pm1": RpcSensorDescription( key="pm1", sub_key="current", @@ -627,6 +657,17 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "energy_light": RpcSensorDescription( + key="light", + sub_key="aenergy", + name="Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), "energy_pm1": RpcSensorDescription( key="pm1", sub_key="aenergy", @@ -837,6 +878,19 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, ), + "temperature_light": RpcSensorDescription( + key="light", + sub_key="temperature", + name="Device temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value=lambda status, _: status["tC"], + suggested_display_precision=1, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + use_polling_coordinator=True, + ), "temperature_0": RpcSensorDescription( key="temperature", sub_key="tC", @@ -925,7 +979,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_rpc( hass, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index b12ad3e4823df2..c1f9b799444dfb 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": { @@ -68,9 +71,6 @@ "ble_scanner_mode": "Bluetooth scanner mode" } } - }, - "abort": { - "ble_unsupported": "Bluetooth support requires firmware version {ble_min_version} or newer." } }, "selector": { @@ -160,6 +160,14 @@ "push_update_failure": { "title": "Shelly device {device_name} push update failure", "description": "Home Assistant is not receiving push updates from the Shelly device {device_name} with IP address {ip_address}. Check the CoIoT configuration in the web panel of the device and your network configuration." + }, + "deprecated_valve_switch": { + "title": "The switch entity for Shelly Gas Valve is deprecated", + "description": "The switch entity for Shelly Gas Valve is deprecated. A valve entity {entity} is available and should be used going forward. For this new valve entity you need to use {service} service." + }, + "deprecated_valve_switch_entity": { + "title": "Deprecated switch entity for Shelly Gas Valve detected in {info}", + "description": "Your Shelly Gas Valve entity `{entity}` is being used in `{info}`. A valve entity is available and should be used going forward.\n\nPlease adjust `{info}` to fix this issue." } } } diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 395b386993ab03..e5d91943a55700 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -5,13 +5,22 @@ from typing import Any, cast from aioshelly.block_device import Block - -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS, RPC_GENERATIONS + +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, + SwitchEntityDescription, +) +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN from homeassistant.config_entries import ConfigEntry 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 GAS_VALVE_OPEN_STATES +from .const import DOMAIN, GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ( BlockEntityDescription, @@ -29,16 +38,18 @@ ) -@dataclass +@dataclass(frozen=True) class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): """Class to describe a BLOCK switch.""" +# This entity description is deprecated and will be removed in Home Assistant 2024.7.0. GAS_VALVE_SWITCH = BlockSwitchDescription( key="valve|valve", name="Valve", available=lambda block: block.valve not in ("failure", "checking"), removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"), + entity_registry_enabled_default=False, ) @@ -48,7 +59,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities) @@ -65,7 +76,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 +88,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 +127,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) @@ -127,7 +147,10 @@ def async_setup_rpc_entry( class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): - """Entity that controls a Gas Valve on Block based Shelly devices.""" + """Entity that controls a Gas Valve on Block based Shelly devices. + + This class is deprecated and will be removed in Home Assistant 2024.7.0. + """ entity_description: BlockSwitchDescription @@ -157,14 +180,61 @@ def icon(self) -> str: async def async_turn_on(self, **kwargs: Any) -> None: """Open valve.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_valve_switch", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switch", + translation_placeholders={ + "entity": f"{VALVE_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "service": f"{VALVE_DOMAIN}.open_valve", + }, + ) self.control_result = await self.set_state(go="open") self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Close valve.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_valve_switch", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switche", + translation_placeholders={ + "entity": f"{VALVE_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "service": f"{VALVE_DOMAIN}.close_valve", + }, + ) self.control_result = await self.set_state(go="close") self.async_write_ha_state() + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + await super().async_added_to_hass() + + 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_valve_{self.entity_id}_{item}", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switch_entity", + translation_placeholders={ + "entity": f"{SWITCH_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "info": item, + }, + ) + @callback def _update_callback(self) -> None: """When device updates, clear control result that overrides state.""" diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index d4528f552885d0..9e8b1505afe165 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -6,6 +6,7 @@ import logging from typing import Any, Final, cast +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.update import ( @@ -34,12 +35,12 @@ 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__) -@dataclass +@dataclass(frozen=True) class RpcUpdateRequiredKeysMixin: """Class for RPC update required keys.""" @@ -47,7 +48,7 @@ class RpcUpdateRequiredKeysMixin: beta: bool -@dataclass +@dataclass(frozen=True) class RestUpdateRequiredKeysMixin: """Class for REST update required keys.""" @@ -55,14 +56,14 @@ class RestUpdateRequiredKeysMixin: beta: bool -@dataclass +@dataclass(frozen=True) class RpcUpdateDescription( RpcEntityDescription, UpdateEntityDescription, RpcUpdateRequiredKeysMixin ): """Class to describe a RPC update.""" -@dataclass +@dataclass(frozen=True) class RestUpdateDescription( RestEntityDescription, UpdateEntityDescription, RestUpdateRequiredKeysMixin ): @@ -119,7 +120,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up update entities for Shelly component.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_rpc( hass, @@ -156,10 +157,15 @@ def __init__( 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 +231,14 @@ def __init__( 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 +345,15 @@ def latest_version(self) -> str | None: 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 4d25812361c05d..d40b22ca50a4fa 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -6,7 +6,16 @@ from aiohttp.web import Request, WebSocketResponse from aioshelly.block_device import COAP, Block, BlockDevice -from aioshelly.const import MODEL_NAMES +from aioshelly.const import ( + BLOCK_GENERATIONS, + MODEL_1L, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_EM3, + MODEL_I3, + MODEL_NAMES, + RPC_GENERATIONS, +) from aioshelly.rpc_device import RpcDevice, WsServer from homeassistant.components.http import HomeAssistantView @@ -25,8 +34,12 @@ from .const import ( BASIC_INPUTS_EVENTS_TYPES, CONF_COAP_PORT, + CONF_GEN, 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 +67,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 +120,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 +150,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 +194,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 @@ -253,15 +270,6 @@ def get_block_device_sleep_period(settings: dict[str, Any]) -> int: return sleep_period * 60 # minutes to seconds -def get_rpc_device_sleep_period(config: dict[str, Any]) -> int: - """Return the device sleep period in seconds or 0 for non sleeping devices. - - sys.sleep.wakeup_period value is deprecated and not available in Shelly - firmware 1.0.0 or later. - """ - return cast(int, config["sys"].get("sleep", {}).get("wakeup_period", 0)) - - def get_rpc_device_wakeup_period(status: dict[str, Any]) -> int: """Return the device wakeup period in seconds or 0 for non sleeping devices.""" return cast(int, status["sys"].get("wakeup_period", 0)) @@ -274,12 +282,12 @@ def get_info_auth(info: dict[str, Any]) -> bool: def get_info_gen(info: dict[str, Any]) -> int: """Return the device generation from shelly info.""" - return int(info.get("gen", 1)) + return int(info.get(CONF_GEN, 1)) def get_model_name(info: dict[str, Any]) -> str: """Return the device model name.""" - if get_info_gen(info) == 2: + if get_info_gen(info) in RPC_GENERATIONS: return cast(str, MODEL_NAMES.get(info["model"], info["model"])) return cast(str, MODEL_NAMES.get(info["type"], info["type"])) @@ -318,7 +326,7 @@ def get_rpc_entity_name( def get_device_entry_gen(entry: ConfigEntry) -> int: """Return the device generation from config entry.""" - return entry.data.get("gen", 1) + return entry.data.get(CONF_GEN, 1) def get_rpc_key_instances(keys_dict: dict[str, Any], key: str) -> list[str]: @@ -353,7 +361,9 @@ def is_block_channel_type_light(settings: dict[str, Any], channel: int) -> bool: def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool: """Return true if rpc channel consumption type is set to light.""" con_types = config["sys"].get("ui_data", {}).get("consumption_types") - return con_types is not None and con_types[channel].lower().startswith("light") + if con_types is None or len(con_types) <= channel: + return False + return cast(str, con_types[channel]).lower().startswith("light") def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: @@ -408,3 +418,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 in BLOCK_GENERATIONS else GEN2_RELEASE_URL diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py new file mode 100644 index 00000000000000..7bc4a9a53290a3 --- /dev/null +++ b/homeassistant/components/shelly/valve.py @@ -0,0 +1,122 @@ +"""Valve for Shelly.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +from aioshelly.block_device import Block +from aioshelly.const import BLOCK_GENERATIONS, MODEL_GAS + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import ShellyBlockCoordinator, get_entry_data +from .entity import ( + BlockEntityDescription, + ShellyBlockAttributeEntity, + async_setup_block_attribute_entities, +) +from .utils import get_device_entry_gen + + +@dataclass(kw_only=True, frozen=True) +class BlockValveDescription(BlockEntityDescription, ValveEntityDescription): + """Class to describe a BLOCK valve.""" + + +GAS_VALVE = BlockValveDescription( + key="valve|valve", + name="Valve", + available=lambda block: block.valve not in ("failure", "checking"), + removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up valves for device.""" + if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS: + async_setup_block_entry(hass, config_entry, async_add_entities) + + +@callback +def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up valve for device.""" + coordinator = get_entry_data(hass)[config_entry.entry_id].block + assert coordinator and coordinator.device.blocks + + if coordinator.model == MODEL_GAS: + async_setup_block_attribute_entities( + hass, + async_add_entities, + coordinator, + {("valve", "valve"): GAS_VALVE}, + BlockShellyValve, + ) + + +class BlockShellyValve(ShellyBlockAttributeEntity, ValveEntity): + """Entity that controls a valve on block based Shelly devices.""" + + entity_description: BlockValveDescription + _attr_device_class = ValveDeviceClass.GAS + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + attribute: str, + description: BlockValveDescription, + ) -> None: + """Initialize block valve.""" + super().__init__(coordinator, block, attribute, description) + self.control_result: dict[str, Any] | None = None + self._attr_is_closed = bool(self.attribute_value == "closed") + + @property + def is_closing(self) -> bool: + """Return if the valve is closing.""" + if self.control_result: + return cast(bool, self.control_result["state"] == "closing") + + return self.attribute_value == "closing" + + @property + def is_opening(self) -> bool: + """Return if the valve is opening.""" + if self.control_result: + return cast(bool, self.control_result["state"] == "opening") + + return self.attribute_value == "opening" + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open valve.""" + self.control_result = await self.set_state(go="open") + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + self.control_result = await self.set_state(go="close") + self.async_write_ha_state() + + @callback + def _update_callback(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + self._attr_is_closed = bool(self.attribute_value == "closed") + super()._update_callback() diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index d89f376d662631..2d95985806710a 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -1,6 +1,6 @@ """A shopping list todo platform.""" -from typing import Any, cast +from typing import cast from homeassistant.components.todo import ( TodoItem, @@ -55,11 +55,10 @@ async def async_create_todo_item(self, item: TodoItem) -> None: async def async_update_todo_item(self, item: TodoItem) -> None: """Update an item to the To-do list.""" - data: dict[str, Any] = {} - if item.summary: - data["name"] = item.summary - if item.status: - data["complete"] = item.status == TodoItemStatus.COMPLETED + data = { + "name": item.summary, + "complete": item.status == TodoItemStatus.COMPLETED, + } try: await self._data.async_update(item.uid, data) except NoMatchingShoppingListItem as err: diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 149a0427ed06eb..e7850a5f9d2d62 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SIAAlarmControlPanelEntityDescription( AlarmControlPanelEntityDescription, SIAEntityDescription, diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index db0845473fdb99..f6e2533be935fb 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SIABinarySensorEntityDescription( BinarySensorEntityDescription, SIAEntityDescription, diff --git a/homeassistant/components/sia/sia_entity_base.py b/homeassistant/components/sia/sia_entity_base.py index a947f9e177ba1c..f6895cc48a987e 100644 --- a/homeassistant/components/sia/sia_entity_base.py +++ b/homeassistant/components/sia/sia_entity_base.py @@ -35,14 +35,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SIARequiredKeysMixin: """Required keys for SIA entities.""" code_consequences: dict[str, StateType | bool] -@dataclass +@dataclass(frozen=True) class SIAEntityDescription(EntityDescription, SIARequiredKeysMixin): """Entity Description for SIA entities.""" diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 7b57fa1fc32060..772b6f9cbf6e38 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Callable, Coroutine, Iterable from datetime import timedelta from typing import Any, cast @@ -336,7 +336,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def extract_system(func: Callable) -> Callable: + def extract_system( + func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]], + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct system for a service call.""" async def wrapper(call: ServiceCall) -> None: diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py index bd60c040f5695d..a11ddc04d646ca 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -19,14 +19,14 @@ from .typing import SystemType -@dataclass +@dataclass(frozen=True) class SimpliSafeButtonDescriptionMixin: """Define an entity description mixin for SimpliSafe buttons.""" push_action: Callable[[System], Awaitable] -@dataclass +@dataclass(frozen=True) class SimpliSafeButtonDescription( ButtonEntityDescription, SimpliSafeButtonDescriptionMixin ): diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index ac02201b92862c..29ad238ac00474 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -1,10 +1,10 @@ """Component to interface with various sirens/chimes.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta +from functools import partial import logging -from typing import Any, TypedDict, cast, final +from typing import TYPE_CHECKING, Any, TypedDict, cast, final import voluptuous as vol @@ -16,24 +16,33 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from .const import ( # noqa: F401 + _DEPRECATED_SUPPORT_DURATION, + _DEPRECATED_SUPPORT_TONES, + _DEPRECATED_SUPPORT_TURN_OFF, + _DEPRECATED_SUPPORT_TURN_ON, + _DEPRECATED_SUPPORT_VOLUME_SET, ATTR_AVAILABLE_TONES, ATTR_DURATION, ATTR_TONE, ATTR_VOLUME_LEVEL, DOMAIN, - SUPPORT_DURATION, - SUPPORT_TONES, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - SUPPORT_VOLUME_SET, SirenEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) @@ -44,6 +53,12 @@ vol.Optional(ATTR_VOLUME_LEVEL): cv.small_float, } +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + class SirenTurnOnServiceParameters(TypedDict, total=False): """Represent possible parameters to siren.turn_on service data dict type.""" @@ -149,14 +164,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SirenEntityDescription(ToggleEntityDescription): +class SirenEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes siren entities.""" available_tones: list[int | str] | dict[int, str] | None = None -class SirenEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "available_tones", + "supported_features", +} + + +class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a siren device.""" _entity_component_unrecorded_attributes = frozenset({ATTR_AVAILABLE_TONES}) @@ -177,7 +197,7 @@ def capability_attributes(self) -> dict[str, Any] | None: return None - @property + @cached_property def available_tones(self) -> list[int | str] | dict[int, str] | None: """Return a list of available tones. @@ -189,7 +209,12 @@ def available_tones(self) -> list[int | str] | dict[int, str] | None: return self.entity_description.available_tones return None - @property + @cached_property def supported_features(self) -> SirenEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = SirenEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features diff --git a/homeassistant/components/siren/const.py b/homeassistant/components/siren/const.py index 374b1d59e2afb3..50c3af61c8dd6e 100644 --- a/homeassistant/components/siren/const.py +++ b/homeassistant/components/siren/const.py @@ -1,8 +1,15 @@ """Constants for the siren component.""" from enum import IntFlag +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "siren" ATTR_TONE: Final = "tone" @@ -24,8 +31,22 @@ class SirenEntityFeature(IntFlag): # These constants are deprecated as of Home Assistant 2022.5 # Please use the SirenEntityFeature enum instead. -SUPPORT_TURN_ON: Final = 1 -SUPPORT_TURN_OFF: Final = 2 -SUPPORT_TONES: Final = 4 -SUPPORT_VOLUME_SET: Final = 8 -SUPPORT_DURATION: Final = 16 +_DEPRECATED_SUPPORT_TURN_ON: Final = DeprecatedConstantEnum( + SirenEntityFeature.TURN_ON, "2025.1" +) +_DEPRECATED_SUPPORT_TURN_OFF: Final = DeprecatedConstantEnum( + SirenEntityFeature.TURN_OFF, "2025.1" +) +_DEPRECATED_SUPPORT_TONES: Final = DeprecatedConstantEnum( + SirenEntityFeature.TONES, "2025.1" +) +_DEPRECATED_SUPPORT_VOLUME_SET: Final = DeprecatedConstantEnum( + SirenEntityFeature.VOLUME_SET, "2025.1" +) +_DEPRECATED_SUPPORT_DURATION: Final = DeprecatedConstantEnum( + SirenEntityFeature.DURATION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index 130196a990d638..7093c5cad207fb 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -22,14 +22,14 @@ from .entity import DOMAIN, SkybellEntity -@dataclass +@dataclass(frozen=True) class SkybellSensorEntityDescriptionMixIn: """Mixin for Skybell sensor.""" value_fn: Callable[[SkybellDevice], StateType | datetime] -@dataclass +@dataclass(frozen=True) class SkybellSensorEntityDescription( SensorEntityDescription, SkybellSensorEntityDescriptionMixIn ): diff --git a/homeassistant/components/sleepiq/button.py b/homeassistant/components/sleepiq/button.py index cca9253d589121..0d9a118d3c934f 100644 --- a/homeassistant/components/sleepiq/button.py +++ b/homeassistant/components/sleepiq/button.py @@ -17,14 +17,14 @@ from .entity import SleepIQEntity -@dataclass +@dataclass(frozen=True) class SleepIQButtonEntityDescriptionMixin: """Describes a SleepIQ Button entity.""" press_action: Callable[[SleepIQBed], Any] -@dataclass +@dataclass(frozen=True) class SleepIQButtonEntityDescription( ButtonEntityDescription, SleepIQButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 4eb6148f9b8d1f..4243684cd52f59 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -11,12 +11,16 @@ IS_IN_BED = "is_in_bed" PRESSURE = "pressure" SLEEP_NUMBER = "sleep_number" +FOOT_WARMING_TIMER = "foot_warming_timer" +FOOT_WARMER = "foot_warmer" ENTITY_TYPES = { ACTUATOR: "Position", FIRMNESS: "Firmness", PRESSURE: "Pressure", IS_IN_BED: "Is In Bed", SLEEP_NUMBER: "SleepNumber", + FOOT_WARMING_TIMER: "Foot Warming Timer", + FOOT_WARMER: "Foot Warmer", } LEFT = "left" diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index 38d8eb320519fc..9a0342aa7ac4c0 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -29,6 +29,14 @@ def device_from_bed(bed: SleepIQBed) -> DeviceInfo: ) +def sleeper_for_side(bed: SleepIQBed, side: str) -> SleepIQSleeper: + """Find the sleeper for a side or the first sleeper.""" + for sleeper in bed.sleepers: + if sleeper.side == side: + return sleeper + return bed.sleepers[0] + + class SleepIQEntity(Entity): """Implementation of a SleepIQ entity.""" diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index 874ae90ec4ae9d..62bd3930c774e0 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.3.7"] + "requirements": ["asyncsleepiq==1.4.1"] } diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 5523f931bd41bb..520e11bb331ffe 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -5,19 +5,26 @@ from dataclasses import dataclass from typing import Any, cast -from asyncsleepiq import SleepIQActuator, SleepIQBed, SleepIQSleeper +from asyncsleepiq import SleepIQActuator, SleepIQBed, SleepIQFootWarmer, SleepIQSleeper from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ACTUATOR, DOMAIN, ENTITY_TYPES, FIRMNESS, ICON_OCCUPIED +from .const import ( + ACTUATOR, + DOMAIN, + ENTITY_TYPES, + FIRMNESS, + FOOT_WARMING_TIMER, + ICON_OCCUPIED, +) from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator -from .entity import SleepIQBedEntity +from .entity import SleepIQBedEntity, sleeper_for_side -@dataclass +@dataclass(frozen=True) class SleepIQNumberEntityDescriptionMixin: """Mixin to describe a SleepIQ number entity.""" @@ -27,7 +34,7 @@ class SleepIQNumberEntityDescriptionMixin: get_unique_id_fn: Callable[[SleepIQBed, Any], str] -@dataclass +@dataclass(frozen=True) class SleepIQNumberEntityDescription( NumberEntityDescription, SleepIQNumberEntityDescriptionMixin ): @@ -69,6 +76,21 @@ def _get_sleeper_unique_id(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: return f"{sleeper.sleeper_id}_{FIRMNESS}" +async def _async_set_foot_warmer_time( + foot_warmer: SleepIQFootWarmer, time: int +) -> None: + foot_warmer.timer = time + + +def _get_foot_warming_name(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) -> str: + sleeper = sleeper_for_side(bed, foot_warmer.side) + return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[FOOT_WARMING_TIMER]}" + + +def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) -> str: + return f"{bed.id}_{foot_warmer.side.value}_{FOOT_WARMING_TIMER}" + + NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { FIRMNESS: SleepIQNumberEntityDescription( key=FIRMNESS, @@ -94,6 +116,18 @@ def _get_sleeper_unique_id(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: get_name_fn=_get_actuator_name, get_unique_id_fn=_get_actuator_unique_id, ), + FOOT_WARMING_TIMER: SleepIQNumberEntityDescription( + key=FOOT_WARMING_TIMER, + native_min_value=30, + native_max_value=360, + native_step=30, + name=ENTITY_TYPES[FOOT_WARMING_TIMER], + icon="mdi:timer", + value_fn=lambda foot_warmer: foot_warmer.timer, + set_value_fn=_async_set_foot_warmer_time, + get_name_fn=_get_foot_warming_name, + get_unique_id_fn=_get_foot_warming_unique_id, + ), } @@ -125,6 +159,15 @@ async def async_setup_entry( NUMBER_DESCRIPTIONS[ACTUATOR], ) ) + for foot_warmer in bed.foundation.foot_warmers: + entities.append( + SleepIQNumberEntity( + data.data_coordinator, + bed, + foot_warmer, + NUMBER_DESCRIPTIONS[FOOT_WARMING_TIMER], + ) + ) async_add_entities(entities) @@ -148,6 +191,8 @@ def __init__( self._attr_name = description.get_name_fn(bed, device) self._attr_unique_id = description.get_unique_id_fn(bed, device) + if description.icon: + self._attr_icon = description.icon super().__init__(coordinator, bed) diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 1609dc2e1161cf..df8d854c9dad43 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -1,16 +1,22 @@ """Support for SleepIQ foundation preset selection.""" from __future__ import annotations -from asyncsleepiq import Side, SleepIQBed, SleepIQPreset +from asyncsleepiq import ( + FootWarmingTemps, + Side, + SleepIQBed, + SleepIQFootWarmer, + SleepIQPreset, +) from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, FOOT_WARMER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator -from .entity import SleepIQBedEntity +from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side async def async_setup_entry( @@ -20,11 +26,17 @@ async def async_setup_entry( ) -> None: """Set up the SleepIQ foundation preset select entities.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - SleepIQSelectEntity(data.data_coordinator, bed, preset) - for bed in data.client.beds.values() - for preset in bed.foundation.presets - ) + entities: list[SleepIQBedEntity] = [] + for bed in data.client.beds.values(): + for preset in bed.foundation.presets: + entities.append(SleepIQSelectEntity(data.data_coordinator, bed, preset)) + for foot_warmer in bed.foundation.foot_warmers: + entities.append( + SleepIQFootWarmingTempSelectEntity( + data.data_coordinator, bed, foot_warmer + ) + ) + async_add_entities(entities) class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], SelectEntity): @@ -59,3 +71,46 @@ async def async_select_option(self, option: str) -> None: await self.preset.set_preset(option) self._attr_current_option = option self.async_write_ha_state() + + +class SleepIQFootWarmingTempSelectEntity( + SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SelectEntity +): + """Representation of a SleepIQ foot warming temperature select entity.""" + + _attr_icon = "mdi:heat-wave" + _attr_options = [e.name.lower() for e in FootWarmingTemps] + _attr_translation_key = "foot_warmer_temp" + + def __init__( + self, + coordinator: SleepIQDataUpdateCoordinator, + bed: SleepIQBed, + foot_warmer: SleepIQFootWarmer, + ) -> None: + """Initialize the select entity.""" + self.foot_warmer = foot_warmer + sleeper = sleeper_for_side(bed, foot_warmer.side) + super().__init__(coordinator, bed, sleeper, FOOT_WARMER) + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + self._attr_current_option = FootWarmingTemps( + self.foot_warmer.temperature + ).name.lower() + + async def async_select_option(self, option: str) -> None: + """Change the current preset.""" + temperature = FootWarmingTemps[option.upper()] + timer = self.foot_warmer.timer or 120 + + if temperature == 0: + await self.foot_warmer.turn_off() + else: + await self.foot_warmer.turn_on(temperature, timer) + + self._attr_current_option = option + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 7a9a4c58464c6f..bdafbfb6c77285 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -23,5 +23,17 @@ } } } + }, + "entity": { + "select": { + "foot_warmer_temp": { + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High" + } + } + } } } diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index f5dc6c16c880ea..16e5d7408c487c 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/sensor.py b/homeassistant/components/smappee/sensor.py index 82bc60936b3394..ad6e5af963e89f 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -18,26 +18,26 @@ from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class SmappeeRequiredKeysMixin: """Mixin for required keys.""" sensor_id: str -@dataclass +@dataclass(frozen=True) class SmappeeSensorEntityDescription(SensorEntityDescription, SmappeeRequiredKeysMixin): """Describes Smappee sensor entity.""" -@dataclass +@dataclass(frozen=True) class SmappeePollingSensorEntityDescription(SmappeeSensorEntityDescription): """Describes Smappee sensor entity.""" local_polling: bool = False -@dataclass +@dataclass(frozen=True) class SmappeeVoltageSensorEntityDescription(SmappeeSensorEntityDescription): """Describes Smappee sensor entity.""" diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json index 9322170dfdfe9b..2bdbf0dabe81da 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -32,8 +32,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_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 52a02aca745652..f07c293939a6a0 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -13,6 +13,10 @@ 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 @@ 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 @@ def temperature_unit(self): 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 @@ async def async_update(self) -> None: 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 @@ def extra_state_attributes(self): 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,64 @@ def hvac_modes(self) -> list[HVACMode]: 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] | None: + """Return the list of available swing modes.""" + supported_swings = None + supported_modes = self._device.status.attributes[ + Attribute.supported_fan_oscillation_modes + ][0] + if supported_modes is not None: + 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: list | None = self._device.status.attributes[ + "supportedAcOptionalMode" + ].value + if supported_modes and 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/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index ebf80e22909c52..6c814b781b2b6b 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -12,10 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index b2d4fbf17c4641..9f1802e7327eb6 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -23,11 +23,13 @@ from .entity import SmartTubEntity PRESET_DAY = "day" +PRESET_READY = "ready" PRESET_MODES = { Spa.HeatMode.AUTO: PRESET_NONE, Spa.HeatMode.ECONOMY: PRESET_ECO, Spa.HeatMode.DAY: PRESET_DAY, + Spa.HeatMode.READY: PRESET_READY, } HEAT_MODES = {v: k for k, v in PRESET_MODES.items()} diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index e8db096f31da24..f2514063a40910 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["smarttub"], "quality_scale": "platinum", - "requirements": ["python-smarttub==0.0.35"] + "requirements": ["python-smarttub==0.0.36"] } diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf4b49e6105d09..d3ba407fa4012a 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -14,10 +14,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_UPDATE_SMARTY diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 6836a0b9f6b670..8760065055102c 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -8,6 +8,7 @@ import email.utils import logging import os +from pathlib import Path import smtplib import voluptuous as vol @@ -31,6 +32,7 @@ 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 @@ def connection_is_valid(self): 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 b711c2f2009433..37250fa6447a56 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}\" from 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 0d51c7543f1b3d..b5673910595255 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/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 7ca31bae61899d..696b079fd5e5f0 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -3,8 +3,9 @@ import binascii import logging -import sys +from pysnmp.entity import config as cfg +from pysnmp.entity.rfc3413.oneliner import cmdgen import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -14,7 +15,6 @@ ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -26,11 +26,6 @@ DEFAULT_COMMUNITY, ) -if sys.version_info < (3, 12): - from pysnmp.entity import config as cfg - from pysnmp.entity.rfc3413.oneliner import cmdgen - - _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( @@ -46,10 +41,6 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "SNMP is not supported on Python 3.12. Please use Python 3.11." - ) scanner = SnmpScanner(config[DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 324a1e493661c3..2756b97157c2e0 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/snmp", "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], - "requirements": ["pysnmplib==5.0.21"] + "requirements": ["pysnmp-lextudio==5.0.31"] } diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 58cd12d611f465..a5915183ad0c94 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -3,8 +3,20 @@ from datetime import timedelta import logging -import sys +from pysnmp.error import PySnmpError +import pysnmp.hlapi.asyncio as hlapi +from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + Udp6TransportTarget, + UdpTransportTarget, + UsmUserData, + getCmd, +) import voluptuous as vol from homeassistant.components.sensor import CONF_STATE_CLASS, PLATFORM_SCHEMA @@ -21,7 +33,6 @@ STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -56,21 +67,6 @@ SNMP_VERSIONS, ) -if sys.version_info < (3, 12): - from pysnmp.error import PySnmpError - import pysnmp.hlapi.asyncio as hlapi - from pysnmp.hlapi.asyncio import ( - CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, - Udp6TransportTarget, - UdpTransportTarget, - UsmUserData, - getCmd, - ) - _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) @@ -115,10 +111,6 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP sensor.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "SNMP is not supported on Python 3.12. Please use Python 3.11." - ) host = config.get(CONF_HOST) port = config.get(CONF_PORT) community = config.get(CONF_COMMUNITY) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index e94c6991601155..d0fe393d55083c 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -2,9 +2,34 @@ from __future__ import annotations import logging -import sys from typing import Any +import pysnmp.hlapi.asyncio as hlapi +from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + UdpTransportTarget, + UsmUserData, + getCmd, + setCmd, +) +from pysnmp.proto.rfc1902 import ( + Counter32, + Counter64, + Gauge32, + Integer, + Integer32, + IpAddress, + Null, + ObjectIdentifier, + OctetString, + Opaque, + TimeTicks, + Unsigned32, +) import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity @@ -17,7 +42,6 @@ CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -43,34 +67,6 @@ SNMP_VERSIONS, ) -if sys.version_info < (3, 12): - import pysnmp.hlapi.asyncio as hlapi - from pysnmp.hlapi.asyncio import ( - CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, - UdpTransportTarget, - UsmUserData, - getCmd, - setCmd, - ) - from pysnmp.proto.rfc1902 import ( - Counter32, - Counter64, - Gauge32, - Integer, - Integer32, - IpAddress, - Null, - ObjectIdentifier, - OctetString, - Opaque, - TimeTicks, - Unsigned32, - ) - _LOGGER = logging.getLogger(__name__) CONF_COMMAND_OID = "command_oid" @@ -81,22 +77,21 @@ DEFAULT_PAYLOAD_OFF = 0 DEFAULT_PAYLOAD_ON = 1 -if sys.version_info < (3, 12): - MAP_SNMP_VARTYPES = { - "Counter32": Counter32, - "Counter64": Counter64, - "Gauge32": Gauge32, - "Integer32": Integer32, - "Integer": Integer, - "IpAddress": IpAddress, - "Null": Null, - # some work todo to support tuple ObjectIdentifier, this just supports str - "ObjectIdentifier": ObjectIdentifier, - "OctetString": OctetString, - "Opaque": Opaque, - "TimeTicks": TimeTicks, - "Unsigned32": Unsigned32, - } +MAP_SNMP_VARTYPES = { + "Counter32": Counter32, + "Counter64": Counter64, + "Gauge32": Gauge32, + "Integer32": Integer32, + "Integer": Integer, + "IpAddress": IpAddress, + "Null": Null, + # some work todo to support tuple ObjectIdentifier, this just supports str + "ObjectIdentifier": ObjectIdentifier, + "OctetString": OctetString, + "Opaque": Opaque, + "TimeTicks": TimeTicks, + "Unsigned32": Unsigned32, +} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -132,10 +127,6 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP switch.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "SNMP is not supported on Python 3.12. Please use Python 3.11." - ) name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 5e298ae2a6f328..bb82da5fc8926e 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -33,14 +33,14 @@ ) -@dataclass +@dataclass(frozen=True) class SolarEdgeSensorEntityRequiredKeyMixin: """Sensor entity description with json_key for SolarEdge.""" json_key: str -@dataclass +@dataclass(frozen=True) class SolarEdgeSensorEntityDescription( SensorEntityDescription, SolarEdgeSensorEntityRequiredKeyMixin ): diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index d0efcd0ec9b78a..0475489a6f4fae 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from contextlib import suppress -from copy import copy -from dataclasses import dataclass +import dataclasses from datetime import timedelta import logging import statistics @@ -51,7 +50,7 @@ ) -@dataclass +@dataclasses.dataclass(frozen=True) class SolarEdgeLocalSensorEntityDescription(SensorEntityDescription): """Describes SolarEdge-local sensor entity.""" @@ -231,10 +230,11 @@ def setup_platform( data = SolarEdgeData(hass, api) # Changing inverter temperature unit. - inverter_temp_description = copy(SENSOR_TYPE_INVERTER_TEMPERATURE) + inverter_temp_description = SENSOR_TYPE_INVERTER_TEMPERATURE if status.inverters.primary.temperature.units.farenheit: - inverter_temp_description.native_unit_of_measurement = ( - UnitOfTemperature.FAHRENHEIT + inverter_temp_description = dataclasses.replace( + inverter_temp_description, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ) # Create entities diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index cd8304a1198e39..a8025c7fc0f061 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -26,7 +26,7 @@ from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class SolarLogSensorEntityDescription(SensorEntityDescription): """Describes Solarlog sensor entity.""" diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 62e923a766dbdf..5f5e2ae7a5fcc8 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 931a33fff56225..abf87b3dde21f9 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 2609e8d893ec5e..90489c0ba347f7 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/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index def44d382ce837..5753d0d23ea4ec 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -31,7 +31,7 @@ from .entity import SonarrEntity -@dataclass +@dataclass(frozen=True) class SonarrSensorEntityDescriptionMixIn(Generic[SonarrDataT]): """Mixin for Sonarr sensor.""" @@ -39,7 +39,7 @@ class SonarrSensorEntityDescriptionMixIn(Generic[SonarrDataT]): value_fn: Callable[[SonarrDataT], StateType] -@dataclass +@dataclass(frozen=True) class SonarrSensorEntityDescription( SensorEntityDescription, SonarrSensorEntityDescriptionMixIn[SonarrDataT] ): diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e6b328cbcb0fc9..c79856c58b60ab 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -290,6 +290,17 @@ def _async_subscription_succeeded(event: SonosEvent) -> None: sub.callback = _async_subscription_succeeded # Hold lock to prevent concurrent subscription attempts await asyncio.sleep(ZGS_SUBSCRIPTION_TIMEOUT * 2) + try: + # Cancel this subscription as we create an autorenewing + # subscription when setting up the SonosSpeaker instance + await sub.unsubscribe() + except ClientError as ex: + # Will be rejected if already replaced by new subscription + _LOGGER.debug( + "Cleanup unsubscription from %s was rejected: %s", ip_address, ex + ) + except (OSError, Timeout) as ex: + _LOGGER.error("Cleanup unsubscription from %s failed: %s", ip_address, ex) async def _async_stop_event_listener(self, event: Event | None = None) -> None: for speaker in self.data.discovered.values(): diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index 96ffeb1df2ad1c..21e440673d6053 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -112,7 +112,7 @@ async def async_generate_speaker_info( payload: dict[str, Any] = {} def get_contents( - item: int | float | str | dict[str, Any] + item: int | float | str | dict[str, Any], ) -> int | float | str | dict[str, Any]: if isinstance(item, (int, float, str)): return item diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 5d36da862ca89b..0e1a1d7daa4499 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.29.1", "sonos-websocket==0.1.2"], + "requirements": ["soco==0.30.0", "sonos-websocket==0.1.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 49caafcc7744aa..27059bba1807f5 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -280,9 +280,9 @@ def state(self) -> MediaPlayerState: 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/sonos/number.py b/homeassistant/components/sonos/number.py index 375ed58035b2ab..c74c5933ecf3a8 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -21,6 +21,7 @@ "bass": (-10, 10), "balance": (-100, 100), "treble": (-10, 10), + "sub_crossover": (50, 110), "sub_gain": (-15, 15), "surround_level": (-15, 15), "music_surround_level": (-15, 15), diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index b73ca6a77e4d71..fea5b5de7deb03 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -154,6 +154,7 @@ def __init__( self.dialog_level: bool | None = None self.night_mode: bool | None = None self.sub_enabled: bool | None = None + self.sub_crossover: int | None = None self.sub_gain: int | None = None self.surround_enabled: bool | None = None self.surround_mode: bool | None = None @@ -561,6 +562,7 @@ def async_update_volume(self, event: SonosEvent) -> None: "audio_delay", "bass", "treble", + "sub_crossover", "sub_gain", "surround_level", "music_surround_level", diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index fb10167f1d0b1e..6f45195c46bec8 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -36,6 +36,9 @@ "treble": { "name": "Treble" }, + "sub_crossover": { + "name": "Sub crossover frequency" + }, "sub_gain": { "name": "Sub gain" }, diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 7af95aab38c0a3..9fc11f7788a94a 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/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index af41c400e0b671..53e80be0cc034e 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -33,7 +33,7 @@ from .coordinator import SpeedTestDataCoordinator -@dataclass +@dataclass(frozen=True) class SpeedtestSensorEntityDescription(SensorEntityDescription): """Class describing Speedtest sensor entities.""" diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index b53b600d5ba75e..02077cbdb43f3f 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -16,8 +16,8 @@ "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]" + "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/__init__.py b/homeassistant/components/sql/__init__.py index 4658e19932cfb6..a4768165c25a67 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -3,6 +3,7 @@ import logging +import sqlparse import voluptuous as vol from homeassistant.components.recorder import CONF_DB_URL, get_instance @@ -38,9 +39,14 @@ def validate_sql_select(value: str) -> str: """Validate that value is a SQL SELECT query.""" - if not value.lstrip().lower().startswith("select"): + if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1: + raise vol.Invalid("Multiple SQL queries are not supported") + if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN": + raise vol.Invalid("Invalid SQL query") + if query_type != "SELECT": + _LOGGER.debug("The SQL query %s is of type %s", query, query_type) raise vol.Invalid("Only SELECT queries allowed") - return value + return str(query[0]) QUERY_SCHEMA = vol.Schema( diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index e00b1f8e402c19..a697bdc51a7061 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -6,8 +6,10 @@ import sqlalchemy from sqlalchemy.engine import Result -from sqlalchemy.exc import NoSuchColumnError, SQLAlchemyError +from sqlalchemy.exc import MultipleResultsFound, NoSuchColumnError, SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker +import sqlparse +from sqlparse.exceptions import SQLParseError import voluptuous as vol from homeassistant import config_entries @@ -80,11 +82,16 @@ ).extend(OPTIONS_SCHEMA.schema) -def validate_sql_select(value: str) -> str | None: +def validate_sql_select(value: str) -> str: """Validate that value is a SQL SELECT query.""" - if not value.lstrip().lower().startswith("select"): - raise ValueError("Incorrect Query") - return value + if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1: + raise MultipleResultsFound + if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN": + raise ValueError + if query_type != "SELECT": + _LOGGER.debug("The SQL query %s is of type %s", query, query_type) + raise SQLParseError + return str(query[0]) def validate_query(db_url: str, query: str, column: str) -> bool: @@ -148,7 +155,7 @@ async def async_step_user( db_url_for_validation = None try: - validate_sql_select(query) + query = validate_sql_select(query) db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column @@ -156,9 +163,14 @@ async def async_step_user( except NoSuchColumnError: errors["column"] = "column_invalid" description_placeholders = {"column": column} + except MultipleResultsFound: + errors["query"] = "multiple_queries" except SQLAlchemyError: errors["db_url"] = "db_url_invalid" - except ValueError: + except SQLParseError: + errors["query"] = "query_no_read_only" + except ValueError as err: + _LOGGER.debug("Invalid query: %s", err) errors["query"] = "query_invalid" options = { @@ -209,7 +221,7 @@ async def async_step_init( name = self.options.get(CONF_NAME, self.config_entry.title) try: - validate_sql_select(query) + query = validate_sql_select(query) db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column @@ -217,9 +229,14 @@ async def async_step_init( except NoSuchColumnError: errors["column"] = "column_invalid" description_placeholders = {"column": column} + except MultipleResultsFound: + errors["query"] = "multiple_queries" except SQLAlchemyError: errors["db_url"] = "db_url_invalid" - except ValueError: + except SQLParseError: + errors["query"] = "query_no_read_only" + except ValueError as err: + _LOGGER.debug("Invalid query: %s", err) errors["query"] = "query_invalid" else: recorder_db = get_instance(self.hass).db_url diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index c63ba19e0ad852..1188a9ec05e055 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.23"] + "requirements": ["SQLAlchemy==2.0.25", "sqlparse==0.4.4"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 3fdc6b2c0794aa..c4e6db4c623a55 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -362,6 +362,8 @@ def _update(self) -> Any: self._query, redact_credentials(str(err)), ) + sess.rollback() + sess.close() return for res in result.mappings(): diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index b4bb73d4b993e0..361585b88760a3 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -6,6 +6,8 @@ "error": { "db_url_invalid": "Database URL invalid", "query_invalid": "SQL Query invalid", + "query_no_read_only": "SQL query must be read-only", + "multiple_queries": "Multiple SQL queries are not supported", "column_invalid": "The column `{column}` is not returned by the query" }, "step": { @@ -61,6 +63,8 @@ "error": { "db_url_invalid": "[%key:component::sql::config::error::db_url_invalid%]", "query_invalid": "[%key:component::sql::config::error::query_invalid%]", + "query_no_read_only": "[%key:component::sql::config::error::query_no_read_only%]", + "multiple_queries": "[%key:component::sql::config::error::multiple_queries%]", "column_invalid": "[%key:component::sql::config::error::column_invalid%]" } }, diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 2c96046b97c7ce..b155c7eddc00be 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_registry import async_get -from .const import DEFAULT_PORT, DOMAIN +from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -49,9 +49,15 @@ def _base_schema(discovery_info=None): ) else: base_schema.update({vol.Required(CONF_PORT, default=DEFAULT_PORT): int}) + base_schema.update( - {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + { + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_HTTPS, default=False): bool, + } ) + return vol.Schema(base_schema) @@ -105,6 +111,7 @@ async def _validate_input(self, data): data[CONF_PORT], data.get(CONF_USERNAME), data.get(CONF_PASSWORD), + https=data[CONF_HTTPS], ) try: diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index d8b67504397786..38a9ef7668f329 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -6,3 +6,4 @@ DISCOVERY_TASK = "discovery_task" DEFAULT_PORT = 9000 SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:") +CONF_HTTPS = "https" diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 43c2868dd69a43..83ca3ff1b00c3f 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.6.3"] + "requirements": ["pysqueezebox==0.7.1"] } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 03457c6a5c01e8..4e3d71eca24436 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -52,6 +52,7 @@ media_source_content_filter, ) from .const import ( + CONF_HTTPS, DISCOVERY_TASK, DOMAIN, KNOWN_PLAYERS, @@ -126,6 +127,7 @@ async def async_setup_entry( password = config.get(CONF_PASSWORD) host = config[CONF_HOST] port = config[CONF_PORT] + https = config.get(CONF_HTTPS, False) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].setdefault(config_entry.entry_id, {}) @@ -134,7 +136,7 @@ async def async_setup_entry( session = async_get_clientsession(hass) _LOGGER.debug("Creating LMS object for %s", host) - lms = Server(session, host, port, username, password) + lms = Server(session, host, port, username, password, https=https) async def _discovery(now=None): """Discover squeezebox players by polling server.""" diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 87881e3414b6a7..fd232851e8a87d 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/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 Logitech Media Server." } }, "edit": { @@ -13,7 +16,8 @@ "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%]" + "password": "[%key:common::config_flow::data::password%]", + "https": "Connect over https (requires reverse proxy)" } } }, diff --git a/homeassistant/components/srp_energy/config_flow.py b/homeassistant/components/srp_energy/config_flow.py index c52574ff3120a8..ac32e005e063f5 100644 --- a/homeassistant/components/srp_energy/config_flow.py +++ b/homeassistant/components/srp_energy/config_flow.py @@ -7,12 +7,12 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from .const import CONF_IS_TOU, DEFAULT_NAME, DOMAIN, LOGGER +from .const import CONF_IS_TOU, DOMAIN, LOGGER async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: @@ -40,46 +40,53 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle a flow initialized by the user.""" - errors = {} - default_title: str = DEFAULT_NAME - - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - if self.hass.config.location_name: - default_title = self.hass.config.location_name - - if user_input: - try: - await validate_input(self.hass, user_input) - except ValueError: - # Thrown when the account id is malformed - errors["base"] = "invalid_account" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") - return self.async_abort(reason="unknown") - else: - return self.async_create_entry(title=default_title, data=user_input) - + @callback + def _show_form(self, errors: dict[str, Any]) -> FlowResult: + """Show the form to the user.""" + LOGGER.debug("Show Form") return self.async_show_form( step_id="user", data_schema=vol.Schema( { + vol.Required( + CONF_NAME, default=self.hass.config.location_name + ): str, vol.Required(CONF_ID): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_IS_TOU, default=False): bool, } ), - errors=errors or {}, + errors=errors, ) + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + LOGGER.debug("Config entry") + errors: dict[str, str] = {} + if not user_input: + return self._show_form(errors) + + try: + await validate_input(self.hass, user_input) + except ValueError: + # Thrown when the account id is malformed + errors["base"] = "invalid_account" + return self._show_form(errors) + except InvalidAuth: + errors["base"] = "invalid_auth" + return self._show_form(errors) + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index bace71aca55b36..b2ab05f43d5ce1 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -11,3 +11,7 @@ PHOENIX_TIME_ZONE = "America/Phoenix" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) + +DEVICE_CONFIG_URL = "https://www.srpnet.com/" +DEVICE_MANUFACTURER = "srpnet.com" +DEVICE_MODEL = "Service Api" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 37aacf4ff25a4e..9e8b8d08de9b25 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -11,10 +11,11 @@ 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 from . import SRPEnergyDataUpdateCoordinator -from .const import DOMAIN +from .const import DEVICE_CONFIG_URL, DEVICE_MANUFACTURER, DEVICE_MODEL, DOMAIN async def async_setup_entry( @@ -37,18 +38,23 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) _attr_translation_key = "energy_usage" def __init__( - self, coordinator: SRPEnergyDataUpdateCoordinator, config_entry: ConfigEntry + self, + coordinator: SRPEnergyDataUpdateCoordinator, + config_entry: ConfigEntry, ) -> None: """Initialize the SrpEntity class.""" super().__init__(coordinator) self._attr_unique_id = f"{config_entry.entry_id}_total_usage" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.entry_id)}, - name="SRP Energy", + name=f"SRP Energy {config_entry.title}", entry_type=DeviceEntryType.SERVICE, + manufacturer=DEVICE_MANUFACTURER, + model=DEVICE_MODEL, + configuration_url=DEVICE_CONFIG_URL, ) @property - def native_value(self) -> float: + def native_value(self) -> StateType: """Return the state of the device.""" return self.coordinator.data diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index fd963411198edb..35195ddb4f2e37 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -17,7 +17,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "entity": { diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index ded663af897687..a2df2c313cdc96 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 @@ def __init__( 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 @@ def __init__( @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 @@ async def async_register_callback( # 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 @@ async def async_start(self) -> None: 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 @@ async def _async_start_ssdp_listeners(self) -> None: 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 @@ def _ssdp_listener_callback( 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 @@ async def _ssdp_listener_process_with_lookup( ) -> 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 @@ def _ssdp_listener_process( 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 @@ async def _async_get_description_dict( 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 @@ async def _async_headers_to_discovery_info( 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/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index bf48b44e5dc553..e6f18190c0bc46 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.36.2"] + "requirements": ["async-upnp-client==0.38.0"] } diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index f0dea6660858db..2940dcf0579f43 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -25,6 +25,12 @@ ) +def _parse_datetime(dt_str: str | None) -> str | None: + if dt_str is None or (parsed := dt_util.parse_datetime(dt_str)) is None: + return None + return parsed.replace(tzinfo=dt_util.UTC).isoformat() + + class StarlineAccount: """StarLine Account class.""" @@ -136,15 +142,14 @@ def device_info(device: StarlineDevice) -> DeviceInfo: model=device.typename, name=device.name, sw_version=device.fw_version, + configuration_url="https://starline-online.ru/", ) @staticmethod def gps_attrs(device: StarlineDevice) -> dict[str, Any]: """Attributes for device tracker.""" return { - "updated": dt_util.utc_from_timestamp(device.position["ts"]) - .replace(tzinfo=None) - .isoformat(), + "updated": dt_util.utc_from_timestamp(device.position["ts"]).isoformat(), "online": device.online, } @@ -154,7 +159,7 @@ def balance_attrs(device: StarlineDevice) -> dict[str, Any]: return { "operator": device.balance.get("operator"), "state": device.balance.get("state"), - "updated": device.balance.get("ts"), + "updated": _parse_datetime(device.balance.get("ts")), } @staticmethod diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index bef724392b7272..c0fe56df71ee30 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -7,6 +7,7 @@ BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,7 +19,7 @@ BinarySensorEntityDescription( key="hbrake", translation_key="hand_brake", - device_class=BinarySensorDeviceClass.POWER, + icon="mdi:car-brake-parking", ), BinarySensorEntityDescription( key="hood", @@ -40,6 +41,24 @@ translation_key="doors", device_class=BinarySensorDeviceClass.LOCK, ), + BinarySensorEntityDescription( + key="hfree", + translation_key="handsfree", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:hand-back-right", + ), + BinarySensorEntityDescription( + key="neutral", + translation_key="neutral", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:car-shift-pattern", + ), + BinarySensorEntityDescription( + key="arm_moving_pb", + translation_key="moving_ban", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:car-off", + ), ) diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py new file mode 100644 index 00000000000000..af6a05206e0b08 --- /dev/null +++ b/homeassistant/components/starline/button.py @@ -0,0 +1,57 @@ +"""Support for StarLine button.""" +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .account import StarlineAccount, StarlineDevice +from .const import DOMAIN +from .entity import StarlineEntity + +BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( + ButtonEntityDescription( + key="poke", + translation_key="horn", + icon="mdi:bullhorn-outline", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the StarLine button.""" + account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + entities = [] + for device in account.api.devices.values(): + if device.support_state: + for description in BUTTON_TYPES: + entities.append(StarlineButton(account, device, description)) + async_add_entities(entities) + + +class StarlineButton(StarlineEntity, ButtonEntity): + """Representation of a StarLine button.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + account: StarlineAccount, + device: StarlineDevice, + description: ButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(account, device, description.key) + self.entity_description = description + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self._device.online + + def press(self): + """Press the button.""" + self._account.api.set_car_state(self._device.device_id, self._key, True) diff --git a/homeassistant/components/starline/const.py b/homeassistant/components/starline/const.py index be9656e70c9714..06465c7b50e95f 100644 --- a/homeassistant/components/starline/const.py +++ b/homeassistant/components/starline/const.py @@ -7,10 +7,11 @@ DOMAIN = "starline" PLATFORMS = [ - Platform.DEVICE_TRACKER, Platform.BINARY_SENSOR, - Platform.SENSOR, + Platform.BUTTON, + Platform.DEVICE_TRACKER, Platform.LOCK, + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 4b787ae5212504..1a43601940eb8c 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -9,6 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfElectricPotential, UnitOfLength, UnitOfTemperature, @@ -60,6 +61,7 @@ key="errors", translation_key="errors", icon="mdi:alert-octagon", + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="mileage", @@ -68,6 +70,12 @@ device_class=SensorDeviceClass.DISTANCE, icon="mdi:counter", ), + SensorEntityDescription( + key="gps_count", + translation_key="gps_count", + icon="mdi:satellite-variant", + native_unit_of_measurement="satellites", + ), ) @@ -130,6 +138,8 @@ def native_value(self): return self._device.errors.get("val") if self._key == "mileage" and self._device.mileage: return self._device.mileage.get("val") + if self._key == "gps_count" and self._device.position: + return self._device.position["sat_qty"] return None @property diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 800fd3a65f3bd4..6f0c42f0882e46 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -54,6 +54,15 @@ }, "doors": { "name": "Doors" + }, + "handsfree": { + "name": "Handsfree" + }, + "neutral": { + "name": "Programmable neutral" + }, + "moving_ban": { + "name": "Moving ban" } }, "device_tracker": { @@ -90,6 +99,9 @@ }, "mileage": { "name": "Mileage" + }, + "gps_count": { + "name": "GPS satellites" } }, "switch": { @@ -104,7 +116,21 @@ }, "horn": { "name": "Horn" + }, + "service_mode": { + "name": "Service mode" } + }, + "button": { + "horn": { + "name": "Horn" + } + } + }, + "issues": { + "deprecated_horn_switch": { + "title": "The Starline Horn switch entity is being removed", + "description": "Using the Horn switch is now deprecated and will be removed in a future version of Home Assistant.\n\nPlease adjust any automations or scripts that use Horn switch entity to instead use the Horn button entity." } }, "services": { diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index ebe27e29e8c230..ef24dd52c02531 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -8,13 +8,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from .account import StarlineAccount, StarlineDevice from .const import DOMAIN from .entity import StarlineEntity -@dataclass +@dataclass(frozen=True) class StarlineRequiredKeysMixin: """Mixin for required keys.""" @@ -22,7 +23,7 @@ class StarlineRequiredKeysMixin: icon_off: str -@dataclass +@dataclass(frozen=True) class StarlineSwitchEntityDescription( SwitchEntityDescription, StarlineRequiredKeysMixin ): @@ -48,12 +49,19 @@ class StarlineSwitchEntityDescription( icon_on="mdi:access-point-network", icon_off="mdi:access-point-network-off", ), + # Deprecated and should be removed in 2024.8 StarlineSwitchEntityDescription( key="poke", translation_key="horn", icon_on="mdi:bullhorn-outline", icon_off="mdi:bullhorn-outline", ), + StarlineSwitchEntityDescription( + key="valet", + translation_key="service_mode", + icon_on="mdi:wrench-clock", + icon_off="mdi:car-wrench", + ), ) @@ -119,6 +127,16 @@ def is_on(self): def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" + if self._key == "poke": + create_issue( + self.hass, + DOMAIN, + "deprecated_horn_switch", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_horn_switch", + ) self._account.api.set_car_state(self._device.device_id, self._key, True) def turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index 87614460096e0a..d346c19fec470a 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -32,14 +32,14 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[StarlinkData], bool | None] -@dataclass +@dataclass(frozen=True) class StarlinkBinarySensorEntityDescription( BinarySensorEntityDescription, StarlinkBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py index 2df9d9b033b66b..daf3122a00d6a5 100644 --- a/homeassistant/components/starlink/button.py +++ b/homeassistant/components/starlink/button.py @@ -31,14 +31,14 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkButtonEntityDescriptionMixin: """Mixin for required keys.""" press_fn: Callable[[StarlinkUpdateCoordinator], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class StarlinkButtonEntityDescription( ButtonEntityDescription, StarlinkButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index eb832741f406ee..f260a7d1c32e9f 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -26,7 +26,7 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkDeviceTrackerEntityDescriptionMixin: """Describes a Starlink device tracker.""" @@ -34,7 +34,7 @@ class StarlinkDeviceTrackerEntityDescriptionMixin: longitude_fn: Callable[[StarlinkData], float] -@dataclass +@dataclass(frozen=True) class StarlinkDeviceTrackerEntityDescription( EntityDescription, StarlinkDeviceTrackerEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index ab76a8dffddd4b..d5116d49305865 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -40,14 +40,14 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[StarlinkData], datetime | StateType] -@dataclass +@dataclass(frozen=True) class StarlinkSensorEntityDescription( SensorEntityDescription, StarlinkSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index 31932fe9854f76..551afa8e73caeb 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -31,7 +31,7 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -40,7 +40,7 @@ class StarlinkSwitchEntityDescriptionMixin: turn_off_fn: Callable[[StarlinkUpdateCoordinator], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class StarlinkSwitchEntityDescription( SwitchEntityDescription, StarlinkSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/steamist/entity.py b/homeassistant/components/steamist/entity.py index 94b3d32eaa4223..78340dab363a2c 100644 --- a/homeassistant/components/steamist/entity.py +++ b/homeassistant/components/steamist/entity.py @@ -4,7 +4,7 @@ from aiosteamist import SteamistStatus from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -14,7 +14,9 @@ class SteamistEntity(CoordinatorEntity[SteamistDataUpdateCoordinator], Entity): - """Representation of an Steamist entity.""" + """Representation of a Steamist entity.""" + + _attr_has_entity_name = True def __init__( self, @@ -25,13 +27,10 @@ def __init__( """Initialize the entity.""" super().__init__(coordinator) self.entity_description = description - if coordinator.device_name: - self._attr_name = f"{coordinator.device_name} {description.name}" self._attr_unique_id = f"{entry.entry_id}_{description.key}" if entry.unique_id: # Only present if UDP broadcast works self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}, - name=entry.data[CONF_NAME], manufacturer="Steamist", model=entry.data[CONF_MODEL], configuration_url=f"http://{entry.data[CONF_HOST]}", diff --git a/homeassistant/components/steamist/sensor.py b/homeassistant/components/steamist/sensor.py index 17cc0e8c27273e..dd51c485b4ec65 100644 --- a/homeassistant/components/steamist/sensor.py +++ b/homeassistant/components/steamist/sensor.py @@ -30,14 +30,14 @@ } -@dataclass +@dataclass(frozen=True) class SteamistSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[SteamistStatus], int | None] -@dataclass +@dataclass(frozen=True) class SteamistSensorEntityDescription( SensorEntityDescription, SteamistSensorEntityDescriptionMixin ): @@ -47,13 +47,13 @@ class SteamistSensorEntityDescription( SENSORS: tuple[SteamistSensorEntityDescription, ...] = ( SteamistSensorEntityDescription( key=_KEY_MINUTES_REMAIN, - name="Steam Minutes Remain", + translation_key="steam_minutes_remain", native_unit_of_measurement=UnitOfTime.MINUTES, value_fn=lambda status: status.minutes_remain, ), SteamistSensorEntityDescription( key=_KEY_TEMP, - name="Steam Temperature", + translation_key="steam_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda status: status.temp, @@ -79,7 +79,7 @@ async def async_setup_entry( class SteamistSensorEntity(SteamistEntity, SensorEntity): - """Representation of an Steamist steam switch.""" + """Representation of a Steamist steam switch.""" entity_description: SteamistSensorEntityDescription diff --git a/homeassistant/components/steamist/strings.json b/homeassistant/components/steamist/strings.json index 8827df6a08a148..7bc3685472af32 100644 --- a/homeassistant/components/steamist/strings.json +++ b/homeassistant/components/steamist/strings.json @@ -28,5 +28,20 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "not_steamist_device": "Not a steamist device" } + }, + "entity": { + "sensor": { + "steam_minutes_remain": { + "name": "Steam minutes remain" + }, + "steam_temperature": { + "name": "Steam temperature" + } + }, + "switch": { + "steam_active": { + "name": "Steam active" + } + } } } diff --git a/homeassistant/components/steamist/switch.py b/homeassistant/components/steamist/switch.py index af9e894b70d1d0..a9a7526c560b87 100644 --- a/homeassistant/components/steamist/switch.py +++ b/homeassistant/components/steamist/switch.py @@ -13,7 +13,9 @@ from .entity import SteamistEntity ACTIVE_SWITCH = SwitchEntityDescription( - key="active", icon="mdi:pot-steam", name="Steam Active" + key="active", + icon="mdi:pot-steam", + translation_key="steam_active", ) @@ -30,7 +32,7 @@ async def async_setup_entry( class SteamistSwitchEntity(SteamistEntity, SwitchEntity): - """Representation of an Steamist steam switch.""" + """Representation of a Steamist steam switch.""" @property def is_on(self) -> bool: diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index a334171abb8fdd..a3441eb76dabad 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -78,7 +78,9 @@ async def async_record(self) -> None: 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/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index c09f6040feda62..82e8777a7e1ae5 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -1,28 +1,31 @@ """Support for Streamlabs Water Monitor devices.""" -import logging -from streamlabswater import streamlabswater +from streamlabswater.streamlabswater import StreamlabsClient import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + ServiceCall, +) +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -DOMAIN = "streamlabswater" - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import StreamlabsCoordinator ATTR_AWAY_MODE = "away_mode" SERVICE_SET_AWAY_MODE = "set_away_mode" AWAY_MODE_AWAY = "away" AWAY_MODE_HOME = "home" -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] - CONF_LOCATION_ID = "location_id" +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=streamlabswater"} CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -36,56 +39,89 @@ ) SET_AWAY_MODE_SCHEMA = vol.Schema( - {vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME])} + { + vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), + vol.Optional(CONF_LOCATION_ID): cv.string, + } ) +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the streamlabs water integration.""" - - conf = config[DOMAIN] - api_key = conf.get(CONF_API_KEY) - location_id = conf.get(CONF_LOCATION_ID) - client = streamlabswater.StreamlabsClient(api_key) - locations = client.get_locations().get("locations") +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the streamlabs water integration.""" - if locations is None: - _LOGGER.error("Unable to retrieve locations. Verify API key") - return False + if DOMAIN not in config: + return True - if location_id is None: - location = locations[0] - location_id = location["locationId"] - _LOGGER.info( - "Streamlabs Water Monitor auto-detected location_id=%s", location_id + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: config[DOMAIN][CONF_API_KEY]}, + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "StreamLabs", + }, ) else: - location = next( - (loc for loc in locations if location_id == loc["locationId"]), None + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=ISSUE_PLACEHOLDER, ) - if location is None: - _LOGGER.error("Supplied location_id is invalid") - return False + return True - location_name = location["name"] - hass.data[DOMAIN] = { - "client": client, - "location_id": location_id, - "location_name": location_name, - } +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up StreamLabs from a config entry.""" + + api_key = entry.data[CONF_API_KEY] + client = StreamlabsClient(api_key) + coordinator = StreamlabsCoordinator(hass, client) + + await coordinator.async_config_entry_first_refresh() - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) def set_away_mode(service: ServiceCall) -> None: """Set the StreamLabsWater Away Mode.""" away_mode = service.data.get(ATTR_AWAY_MODE) + location_id = ( + service.data.get(CONF_LOCATION_ID) or list(coordinator.data.values())[0] + ) client.update_location(location_id, away_mode) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA ) 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/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index 43465fb99ae922..efc0eb24dd7e14 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -1,80 +1,39 @@ """Support for Streamlabs Water Monitor Away Mode.""" from __future__ import annotations -from datetime import timedelta - from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle - -from . import DOMAIN as STREAMLABSWATER_DOMAIN - -DEPENDS = ["streamlabswater"] - -MIN_TIME_BETWEEN_LOCATION_UPDATES = timedelta(seconds=60) -ATTR_LOCATION_ID = "location_id" -NAME_AWAY_MODE = "Water Away Mode" +from . import StreamlabsCoordinator +from .const import DOMAIN +from .entity import StreamlabsWaterEntity -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_devices: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the StreamLabsWater mode sensor.""" - client = hass.data[STREAMLABSWATER_DOMAIN]["client"] - location_id = hass.data[STREAMLABSWATER_DOMAIN]["location_id"] - location_name = hass.data[STREAMLABSWATER_DOMAIN]["location_name"] - - streamlabs_location_data = StreamlabsLocationData(location_id, client) - streamlabs_location_data.update() - - add_devices([StreamlabsAwayMode(location_name, streamlabs_location_data)]) - + """Set up Streamlabs water binary sensor from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] -class StreamlabsLocationData: - """Track and query location data.""" + async_add_entities( + StreamlabsAwayMode(coordinator, location_id) for location_id in coordinator.data + ) - def __init__(self, location_id, client): - """Initialize the location data.""" - self._location_id = location_id - self._client = client - self._is_away = None - @Throttle(MIN_TIME_BETWEEN_LOCATION_UPDATES) - def update(self): - """Query and store location data.""" - location = self._client.get_location(self._location_id) - self._is_away = location["homeAway"] == "away" - - def is_away(self): - """Return whether away more is enabled.""" - return self._is_away - - -class StreamlabsAwayMode(BinarySensorEntity): +class StreamlabsAwayMode(StreamlabsWaterEntity, BinarySensorEntity): """Monitor the away mode state.""" - def __init__(self, location_name, streamlabs_location_data): - """Initialize the away mode device.""" - self._location_name = location_name - self._streamlabs_location_data = streamlabs_location_data - self._is_away = None + _attr_translation_key = "away_mode" - @property - def name(self): - """Return the name for away mode.""" - return f"{self._location_name} {NAME_AWAY_MODE}" + def __init__(self, coordinator: StreamlabsCoordinator, location_id: str) -> None: + """Initialize the away mode device.""" + super().__init__(coordinator, location_id, "away_mode") @property - def is_on(self): + def is_on(self) -> bool: """Return if away mode is on.""" - return self._streamlabs_location_data.is_away() - - def update(self) -> None: - """Retrieve the latest location data and away mode state.""" - self._streamlabs_location_data.update() + return self.location_data.is_away diff --git a/homeassistant/components/streamlabswater/config_flow.py b/homeassistant/components/streamlabswater/config_flow.py new file mode 100644 index 00000000000000..5cede037d5aff9 --- /dev/null +++ b/homeassistant/components/streamlabswater/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for StreamLabs integration.""" +from __future__ import annotations + +from typing import Any + +from streamlabswater.streamlabswater import StreamlabsClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN, LOGGER + + +async def validate_input(hass: HomeAssistant, api_key: str) -> None: + """Validate the user input allows us to connect.""" + client = StreamlabsClient(api_key) + response = await hass.async_add_executor_job(client.get_locations) + locations = response.get("locations") + + if locations is None: + raise CannotConnect + + +class StreamlabsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for StreamLabs.""" + + 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: + self._async_abort_entries_match(user_input) + try: + await validate_input(self.hass, user_input[CONF_API_KEY]) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Streamlabs", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import the yaml config.""" + self._async_abort_entries_match(user_input) + try: + await validate_input(self.hass, user_input[CONF_API_KEY]) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + return self.async_create_entry(title="Streamlabs", data=user_input) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/streamlabswater/const.py b/homeassistant/components/streamlabswater/const.py new file mode 100644 index 00000000000000..ee407d376d4ca1 --- /dev/null +++ b/homeassistant/components/streamlabswater/const.py @@ -0,0 +1,6 @@ +"""Constants for the StreamLabs integration.""" +import logging + +DOMAIN = "streamlabswater" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py new file mode 100644 index 00000000000000..bcb2e7790d4c07 --- /dev/null +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -0,0 +1,57 @@ +"""Coordinator for Streamlabs water integration.""" +from dataclasses import dataclass +from datetime import timedelta + +from streamlabswater.streamlabswater import StreamlabsClient + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import LOGGER + + +@dataclass(slots=True) +class StreamlabsData: + """Class to hold Streamlabs data.""" + + is_away: bool + name: str + daily_usage: float + monthly_usage: float + yearly_usage: float + + +class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): + """Coordinator for Streamlabs.""" + + def __init__( + self, + hass: HomeAssistant, + client: StreamlabsClient, + ) -> None: + """Coordinator for Streamlabs.""" + super().__init__( + hass, + LOGGER, + name="Streamlabs", + update_interval=timedelta(seconds=60), + ) + self.client = client + + async def _async_update_data(self) -> dict[str, StreamlabsData]: + return await self.hass.async_add_executor_job(self._update_data) + + def _update_data(self) -> dict[str, StreamlabsData]: + locations = self.client.get_locations() + res = {} + for location in locations["locations"]: + location_id = location["locationId"] + water_usage = self.client.get_water_usage_summary(location_id) + res[location_id] = StreamlabsData( + is_away=location["homeAway"] == "away", + name=location["name"], + daily_usage=water_usage["today"], + monthly_usage=water_usage["thisMonth"], + yearly_usage=water_usage["thisYear"], + ) + return res diff --git a/homeassistant/components/streamlabswater/entity.py b/homeassistant/components/streamlabswater/entity.py new file mode 100644 index 00000000000000..4458523a07f845 --- /dev/null +++ b/homeassistant/components/streamlabswater/entity.py @@ -0,0 +1,31 @@ +"""Base entity for Streamlabs integration.""" +from homeassistant.core import DOMAIN +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import StreamlabsCoordinator, StreamlabsData + + +class StreamlabsWaterEntity(CoordinatorEntity[StreamlabsCoordinator]): + """Defines a base Streamlabs entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: StreamlabsCoordinator, + location_id: str, + key: str, + ) -> None: + """Initialize the Streamlabs entity.""" + super().__init__(coordinator) + self._location_id = location_id + self._attr_unique_id = f"{location_id}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, location_id)}, name=self.location_data.name + ) + + @property + def location_data(self) -> StreamlabsData: + """Returns the data object.""" + return self.coordinator.data[self._location_id] diff --git a/homeassistant/components/streamlabswater/manifest.json b/homeassistant/components/streamlabswater/manifest.json index fae19ca3e7a97f..ec076bd52ec217 100644 --- a/homeassistant/components/streamlabswater/manifest.json +++ b/homeassistant/components/streamlabswater/manifest.json @@ -2,6 +2,7 @@ "domain": "streamlabswater", "name": "StreamLabs", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/streamlabswater", "iot_class": "cloud_polling", "loggers": ["streamlabswater"], diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 42cf2bb588f818..d9bb76814b514d 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -1,132 +1,92 @@ """Support for Streamlabs Water Monitor Usage.""" from __future__ import annotations -from datetime import timedelta - -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle - -from . import DOMAIN as STREAMLABSWATER_DOMAIN - -DEPENDENCIES = ["streamlabswater"] - -WATER_ICON = "mdi:water" -MIN_TIME_BETWEEN_USAGE_UPDATES = timedelta(seconds=60) - -NAME_DAILY_USAGE = "Daily Water" -NAME_MONTHLY_USAGE = "Monthly Water" -NAME_YEARLY_USAGE = "Yearly Water" - - -def setup_platform( +from homeassistant.helpers.typing import StateType + +from . import StreamlabsCoordinator +from .const import DOMAIN +from .coordinator import StreamlabsData +from .entity import StreamlabsWaterEntity + + +@dataclass(frozen=True, kw_only=True) +class StreamlabsWaterSensorEntityDescription(SensorEntityDescription): + """Streamlabs sensor entity description.""" + + value_fn: Callable[[StreamlabsData], StateType] + + +SENSORS: tuple[StreamlabsWaterSensorEntityDescription, ...] = ( + StreamlabsWaterSensorEntityDescription( + key="daily_usage", + translation_key="daily_usage", + native_unit_of_measurement=UnitOfVolume.GALLONS, + device_class=SensorDeviceClass.WATER, + suggested_display_precision=1, + value_fn=lambda data: data.daily_usage, + ), + StreamlabsWaterSensorEntityDescription( + key="monthly_usage", + translation_key="monthly_usage", + native_unit_of_measurement=UnitOfVolume.GALLONS, + device_class=SensorDeviceClass.WATER, + suggested_display_precision=1, + value_fn=lambda data: data.monthly_usage, + ), + StreamlabsWaterSensorEntityDescription( + key="yearly_usage", + translation_key="yearly_usage", + native_unit_of_measurement=UnitOfVolume.GALLONS, + device_class=SensorDeviceClass.WATER, + suggested_display_precision=1, + value_fn=lambda data: data.yearly_usage, + ), +) + + +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_devices: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up water usage sensors.""" - client = hass.data[STREAMLABSWATER_DOMAIN]["client"] - location_id = hass.data[STREAMLABSWATER_DOMAIN]["location_id"] - location_name = hass.data[STREAMLABSWATER_DOMAIN]["location_name"] - - streamlabs_usage_data = StreamlabsUsageData(location_id, client) - streamlabs_usage_data.update() - - add_devices( - [ - StreamLabsDailyUsage(location_name, streamlabs_usage_data), - StreamLabsMonthlyUsage(location_name, streamlabs_usage_data), - StreamLabsYearlyUsage(location_name, streamlabs_usage_data), - ] - ) + """Set up Streamlabs water sensor from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] - -class StreamlabsUsageData: - """Track and query usage data.""" - - def __init__(self, location_id, client): - """Initialize the usage data.""" - self._location_id = location_id - self._client = client - self._today = None - self._this_month = None - self._this_year = None - - @Throttle(MIN_TIME_BETWEEN_USAGE_UPDATES) - def update(self): - """Query and store usage data.""" - water_usage = self._client.get_water_usage_summary(self._location_id) - self._today = round(water_usage["today"], 1) - self._this_month = round(water_usage["thisMonth"], 1) - self._this_year = round(water_usage["thisYear"], 1) - - def get_daily_usage(self): - """Return the day's usage.""" - return self._today - - def get_monthly_usage(self): - """Return the month's usage.""" - return self._this_month - - def get_yearly_usage(self): - """Return the year's usage.""" - return self._this_year + async_add_entities( + StreamLabsSensor(coordinator, location_id, entity_description) + for location_id in coordinator.data + for entity_description in SENSORS + ) -class StreamLabsDailyUsage(SensorEntity): +class StreamLabsSensor(StreamlabsWaterEntity, SensorEntity): """Monitors the daily water usage.""" - _attr_device_class = SensorDeviceClass.WATER - _attr_native_unit_of_measurement = UnitOfVolume.GALLONS + entity_description: StreamlabsWaterSensorEntityDescription - def __init__(self, location_name, streamlabs_usage_data): + def __init__( + self, + coordinator: StreamlabsCoordinator, + location_id: str, + entity_description: StreamlabsWaterSensorEntityDescription, + ) -> None: """Initialize the daily water usage device.""" - self._location_name = location_name - self._streamlabs_usage_data = streamlabs_usage_data - self._state = None + super().__init__(coordinator, location_id, entity_description.key) + self.entity_description = entity_description @property - def name(self) -> str: - """Return the name for daily usage.""" - return f"{self._location_name} {NAME_DAILY_USAGE}" - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the current daily usage.""" - return self._streamlabs_usage_data.get_daily_usage() - - def update(self) -> None: - """Retrieve the latest daily usage.""" - self._streamlabs_usage_data.update() - - -class StreamLabsMonthlyUsage(StreamLabsDailyUsage): - """Monitors the monthly water usage.""" - - @property - def name(self) -> str: - """Return the name for monthly usage.""" - return f"{self._location_name} {NAME_MONTHLY_USAGE}" - - @property - def native_value(self): - """Return the current monthly usage.""" - return self._streamlabs_usage_data.get_monthly_usage() - - -class StreamLabsYearlyUsage(StreamLabsDailyUsage): - """Monitors the yearly water usage.""" - - @property - def name(self) -> str: - """Return the name for yearly usage.""" - return f"{self._location_name} {NAME_YEARLY_USAGE}" - - @property - def native_value(self): - """Return the current yearly usage.""" - return self._streamlabs_usage_data.get_yearly_usage() + return self.entity_description.value_fn(self.location_data) diff --git a/homeassistant/components/streamlabswater/services.yaml b/homeassistant/components/streamlabswater/services.yaml index 7504a9111236f8..cd828fd3fede78 100644 --- a/homeassistant/components/streamlabswater/services.yaml +++ b/homeassistant/components/streamlabswater/services.yaml @@ -7,3 +7,6 @@ set_away_mode: options: - "away" - "home" + location_id: + selector: + text: diff --git a/homeassistant/components/streamlabswater/strings.json b/homeassistant/components/streamlabswater/strings.json index 56b35ab10442d4..204f7e831efc35 100644 --- a/homeassistant/components/streamlabswater/strings.json +++ b/homeassistant/components/streamlabswater/strings.json @@ -1,4 +1,20 @@ { + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, "services": { "set_away_mode": { "name": "Set away mode", @@ -7,8 +23,40 @@ "away_mode": { "name": "Away mode", "description": "Home or away." + }, + "location_id": { + "name": "Location ID", + "description": "The location ID of the Streamlabs Water Monitor." } } } + }, + "entity": { + "binary_sensor": { + "away_mode": { + "name": "Away mode" + } + }, + "sensor": { + "daily_usage": { + "name": "Daily usage" + }, + "monthly_usage": { + "name": "Monthly usage" + }, + "yearly_usage": { + "name": "Yearly usage" + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Streamlabs water YAML configuration import failed", + "description": "Configuring Streamlabs water using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to Streamlabs water works and restart Home Assistant to try again or remove the Streamlabs water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Streamlabs water YAML configuration import failed", + "description": "Configuring Streamlabs water using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the Streamlabs water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } } } diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 862f59d5f6d97f..bd1cfbca3d241f 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -6,9 +6,10 @@ import logging from typing import Any +from homeassistant.config import config_per_platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_per_platform, discovery -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform from .const import ( @@ -28,9 +29,6 @@ @callback def async_default_provider(hass: HomeAssistant) -> str | None: """Return the domain of the default provider.""" - if "cloud" in hass.data[DATA_PROVIDERS]: - return "cloud" - return next(iter(hass.data[DATA_PROVIDERS]), None) @@ -39,11 +37,12 @@ def async_get_provider( hass: HomeAssistant, domain: str | None = None ) -> Provider | None: """Return provider.""" + providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] if domain: - return hass.data[DATA_PROVIDERS].get(domain) + return providers.get(domain) provider = async_default_provider(hass) - return hass.data[DATA_PROVIDERS][provider] if provider is not None else None + return providers[provider] if provider is not None else None @callback @@ -53,7 +52,11 @@ def async_setup_legacy( """Set up legacy speech-to-text providers.""" providers = hass.data[DATA_PROVIDERS] = {} - async def async_setup_platform(p_type, p_config=None, discovery_info=None): + async def async_setup_platform( + p_type: str, + p_config: ConfigType | None = None, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: """Set up an STT platform.""" if p_config is None: p_config = {} @@ -75,7 +78,9 @@ async def async_setup_platform(p_type, p_config=None, discovery_info=None): return # Add discovery support - async def async_platform_discovered(platform, info): + async def async_platform_discovered( + platform: str, info: DiscoveryInfoType | None + ) -> None: """Handle for discovered platform.""" await async_setup_platform(platform, discovery_info=info) @@ -84,6 +89,7 @@ async def async_platform_discovered(platform, info): return [ async_setup_platform(p_type, p_config) for p_type, p_config in config_per_platform(config, DOMAIN) + if p_type ] diff --git a/homeassistant/components/stt/manifest.json b/homeassistant/components/stt/manifest.json index 53bb7fa19379e2..265c3363e2b467 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/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 091a281defca91..8a22391284fd68 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -6,7 +6,13 @@ from subarulink import Controller as SubaruAPI, InvalidCredentials, SubaruException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_PIN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client @@ -14,7 +20,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - CONF_COUNTRY, CONF_UPDATE_ENABLED, COORDINATOR_NAME, DOMAIN, diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py index 6d1d5015ed3f8c..b21feab784374e 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -15,12 +15,18 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_PIN, + CONF_USERNAME, +) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv -from .const import CONF_COUNTRY, CONF_UPDATE_ENABLED, DOMAIN +from .const import CONF_UPDATE_ENABLED, DOMAIN _LOGGER = logging.getLogger(__name__) CONF_CONTACT_METHOD = "contact_method" diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 9c94ed353617b4..ab76c363f7eb8f 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -7,7 +7,6 @@ FETCH_INTERVAL = 300 UPDATE_INTERVAL = 7200 CONF_UPDATE_ENABLED = "update_enabled" -CONF_COUNTRY = "country" # entry fields ENTRY_CONTROLLER = "controller" diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index a2d07a8d0a4de1..66c3981705c369 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -1 +1,48 @@ -"""France Suez Water integration.""" +"""The Suez Water integration.""" +from __future__ import annotations + +from pysuez import SuezClient +from pysuez.client import PySuezError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import CONF_COUNTER_ID, DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Suez Water from a config entry.""" + + def get_client() -> SuezClient: + try: + client = SuezClient( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_COUNTER_ID], + provider=None, + ) + if not client.check_credentials(): + raise ConfigEntryError + return client + except PySuezError: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = await hass.async_add_executor_job(get_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.""" + 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/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py new file mode 100644 index 00000000000000..ba288c90e34058 --- /dev/null +++ b/homeassistant/components/suez_water/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow for Suez Water integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pysuez import SuezClient +from pysuez.client import PySuezError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import CONF_COUNTER_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_COUNTER_ID): str, + } +) + + +def validate_input(data: dict[str, Any]) -> None: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + try: + client = SuezClient( + data[CONF_USERNAME], + data[CONF_PASSWORD], + data[CONF_COUNTER_ID], + provider=None, + ) + if not client.check_credentials(): + raise InvalidAuth + except PySuezError: + raise CannotConnect + + +class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Suez Water.""" + + 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: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + try: + await self.hass.async_add_executor_job(validate_input, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + 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 + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import the yaml config.""" + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + try: + await self.hass.async_add_executor_job(validate_input, user_input) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/suez_water/const.py b/homeassistant/components/suez_water/const.py new file mode 100644 index 00000000000000..7afc0d3ce3ea51 --- /dev/null +++ b/homeassistant/components/suez_water/const.py @@ -0,0 +1,5 @@ +"""Constants for the Suez Water integration.""" + +DOMAIN = "suez_water" + +CONF_COUNTER_ID = "counter_id" diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 15c346fadabe51..4503d7a1119177 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -2,8 +2,9 @@ "domain": "suez_water", "name": "Suez Water", "codeowners": ["@ooii"], + "config_flow": true, "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 43075276be61a4..4602df27748b98 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -13,18 +13,22 @@ SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.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 CONF_COUNTER_ID, DOMAIN + _LOGGER = logging.getLogger(__name__) +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=suez_water"} SCAN_INTERVAL = timedelta(hours=12) -CONF_COUNTER_ID = "counter_id" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, @@ -34,28 +38,58 @@ ) -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.""" - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - counter_id = config[CONF_COUNTER_ID] - try: - client = SuezClient(username, password, counter_id) - - if not client.check_credentials(): - _LOGGER.warning("Wrong username and/or password") - return - - except PySuezError: - _LOGGER.warning("Unable to create Suez Client") - return - - add_entities([SuezSensor(client)], True) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Suez Water", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Suez Water sensor from a config entry.""" + client = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SuezSensor(client)], True) class SuezSensor(SensorEntity): @@ -71,7 +105,7 @@ def __init__(self, client: SuezClient) -> None: self.client = client self._attr_extra_state_attributes = {} - def _fetch_data(self): + def _fetch_data(self) -> None: """Fetch latest data from Suez.""" try: self.client.update() diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json new file mode 100644 index 00000000000000..09df3ead17f3f4 --- /dev/null +++ b/homeassistant/components/suez_water/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "counter_id": "Counter id" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The Suez water YAML configuration import failed", + "description": "Configuring Suez water using YAML is being removed but there was an authentication error importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Suez water YAML configuration import failed", + "description": "Configuring Suez water using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to Suez water works and restart Home Assistant to try again or remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Suez water YAML configuration import failed", + "description": "Configuring Suez water using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 0f867f9b7c425c..384e356fdd6eac 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -26,19 +26,14 @@ ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" -@dataclass -class SunEntityDescriptionMixin: - """Mixin for required Sun base description keys.""" +@dataclass(kw_only=True, frozen=True) +class SunSensorEntityDescription(SensorEntityDescription): + """Describes a Sun sensor entity.""" value_fn: Callable[[Sun], StateType | datetime] signal: str -@dataclass -class SunSensorEntityDescription(SensorEntityDescription, SunEntityDescriptionMixin): - """Describes Sun sensor entity.""" - - SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( SunSensorEntityDescription( key="next_dawn", diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py new file mode 100644 index 00000000000000..9da91ccda0fe3c --- /dev/null +++ b/homeassistant/components/sunweg/__init__.py @@ -0,0 +1,196 @@ +"""The Sun WEG inverter sensor integration.""" +import datetime +import json +import logging + +from sunweg.api import APIHelper +from sunweg.plant import Plant + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType, UndefinedType +from homeassistant.util import Throttle + +from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS, DeviceType + +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Load the saved entities.""" + api = APIHelper(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + if not await hass.async_add_executor_job(api.authenticate): + _LOGGER.error("Username or Password may be incorrect!") + return False + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SunWEGData( + api, entry.data[CONF_PLANT_ID] + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data[DOMAIN].pop(entry.entry_id) + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class SunWEGData: + """The class for handling data retrieval.""" + + def __init__( + self, + api: APIHelper, + plant_id: int, + ) -> None: + """Initialize the probe.""" + + self.api = api + self.plant_id = plant_id + self.data: Plant = None + self.previous_values: dict = {} + + @Throttle(SCAN_INTERVAL) + def update(self) -> None: + """Update probe data.""" + _LOGGER.debug("Updating data for plant %s", self.plant_id) + try: + self.data = self.api.plant(self.plant_id) + for inverter in self.data.inverters: + self.api.complete_inverter(inverter) + except json.decoder.JSONDecodeError: + _LOGGER.error("Unable to fetch data from SunWEG server") + _LOGGER.debug("Finished updating data for plant %s", self.plant_id) + + def get_api_value( + self, + variable: str, + device_type: DeviceType, + inverter_id: int = 0, + deep_name: str | None = None, + ): + """Retrieve from a Plant the desired variable value.""" + if device_type == DeviceType.TOTAL: + return self.data.__dict__.get(variable) + + inverter_list = [i for i in self.data.inverters if i.id == inverter_id] + if len(inverter_list) == 0: + return None + inverter = inverter_list[0] + + if device_type == DeviceType.INVERTER: + return inverter.__dict__.get(variable) + if device_type == DeviceType.PHASE: + for phase in inverter.phases: + if phase.name == deep_name: + return phase.__dict__.get(variable) + elif device_type == DeviceType.STRING: + for mppt in inverter.mppts: + for string in mppt.strings: + if string.name == deep_name: + return string.__dict__.get(variable) + return None + + def get_data( + self, + *, + api_variable_key: str, + api_variable_unit: str | None, + deep_name: str | None, + device_type: DeviceType, + inverter_id: int, + name: str | UndefinedType | None, + native_unit_of_measurement: str | None, + never_resets: bool, + previous_value_drop_threshold: float | None, + ) -> tuple[StateType | datetime.datetime, str | None]: + """Get the data.""" + _LOGGER.debug( + "Data request for: %s", + name, + ) + variable = api_variable_key + previous_unit = native_unit_of_measurement + api_value = self.get_api_value(variable, device_type, inverter_id, deep_name) + previous_value = self.previous_values.get(variable) + return_value = api_value + if api_variable_unit is not None: + native_unit_of_measurement = self.get_api_value( + api_variable_unit, + device_type, + inverter_id, + deep_name, + ) + + # If we have a 'drop threshold' specified, then check it and correct if needed + if ( + previous_value_drop_threshold is not None + and previous_value is not None + and api_value is not None + and previous_unit == native_unit_of_measurement + ): + _LOGGER.debug( + ( + "%s - Drop threshold specified (%s), checking for drop... API" + " Value: %s, Previous Value: %s" + ), + name, + previous_value_drop_threshold, + api_value, + previous_value, + ) + diff = float(api_value) - float(previous_value) + + # Check if the value has dropped (negative value i.e. < 0) and it has only + # dropped by a small amount, if so, use the previous value. + # Note - The energy dashboard takes care of drops within 10% + # of the current value, however if the value is low e.g. 0.2 + # and drops by 0.1 it classes as a reset. + if -(previous_value_drop_threshold) <= diff < 0: + _LOGGER.debug( + ( + "Diff is negative, but only by a small amount therefore not a" + " nightly reset, using previous value (%s) instead of api value" + " (%s)" + ), + previous_value, + api_value, + ) + return_value = previous_value + else: + _LOGGER.debug("%s - No drop detected, using API value", name) + + # Lifetime total values should always be increasing, they will never reset, + # however the API sometimes returns 0 values when the clock turns to 00:00 + # local time in that scenario we should just return the previous value + # Scenarios: + # 1 - System has a genuine 0 value when it it first commissioned: + # - will return 0 until a non-zero value is registered + # 2 - System has been running fine but temporarily resets to 0 briefly + # at midnight: + # - will return the previous value + # 3 - HA is restarted during the midnight 'outage' - Not handled: + # - Previous value will not exist meaning 0 will be returned + # - This is an edge case that would be better handled by looking + # up the previous value of the entity from the recorder + if never_resets and api_value == 0 and previous_value: + _LOGGER.debug( + ( + "API value is 0, but this value should never reset, returning" + " previous value (%s) instead" + ), + previous_value, + ) + return_value = previous_value + + self.previous_values[variable] = return_value + + return (return_value, native_unit_of_measurement) diff --git a/homeassistant/components/sunweg/config_flow.py b/homeassistant/components/sunweg/config_flow.py new file mode 100644 index 00000000000000..cd24a4722e9d60 --- /dev/null +++ b/homeassistant/components/sunweg/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Sun WEG integration.""" +from sunweg.api import APIHelper +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_PLANT_ID, DOMAIN + + +class SunWEGConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow class.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialise sun weg server flow.""" + self.api: APIHelper = None + self.data: dict = {} + + @callback + def _async_show_user_form(self, errors=None) -> FlowResult: + """Show the form to the user.""" + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle the start of the config flow.""" + if not user_input: + return self._async_show_user_form() + + # Initialise the library with the username & password + self.api = APIHelper(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + login_response = await self.hass.async_add_executor_job(self.api.authenticate) + + if not login_response: + return self._async_show_user_form({"base": "invalid_auth"}) + + # Store authentication info + self.data = user_input + return await self.async_step_plant() + + async def async_step_plant(self, user_input=None) -> FlowResult: + """Handle adding a "plant" to Home Assistant.""" + plant_list = await self.hass.async_add_executor_job(self.api.listPlants) + + if len(plant_list) == 0: + return self.async_abort(reason="no_plants") + + plants = {plant.id: plant.name for plant in plant_list} + + if user_input is None and len(plant_list) > 1: + data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) + + return self.async_show_form(step_id="plant", data_schema=data_schema) + + if user_input is None and len(plant_list) == 1: + user_input = {CONF_PLANT_ID: plant_list[0].id} + + user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] + await self.async_set_unique_id(user_input[CONF_PLANT_ID]) + self._abort_if_unique_id_configured() + self.data.update(user_input) + return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) diff --git a/homeassistant/components/sunweg/const.py b/homeassistant/components/sunweg/const.py new file mode 100644 index 00000000000000..e4b2b242abfac3 --- /dev/null +++ b/homeassistant/components/sunweg/const.py @@ -0,0 +1,24 @@ +"""Define constants for the Sun WEG component.""" +from enum import Enum + +from homeassistant.const import Platform + + +class DeviceType(Enum): + """Device Type Enum.""" + + TOTAL = 1 + INVERTER = 2 + PHASE = 3 + STRING = 4 + + +CONF_PLANT_ID = "plant_id" + +DEFAULT_PLANT_ID = 0 + +DEFAULT_NAME = "Sun WEG" + +DOMAIN = "sunweg" + +PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json new file mode 100644 index 00000000000000..de0b3406f0556b --- /dev/null +++ b/homeassistant/components/sunweg/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sunweg", + "name": "Sun WEG", + "codeowners": ["@rokam"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sunweg/", + "iot_class": "cloud_polling", + "loggers": ["sunweg"], + "requirements": ["sunweg==2.0.3"] +} diff --git a/homeassistant/components/sunweg/sensor.py b/homeassistant/components/sunweg/sensor.py new file mode 100644 index 00000000000000..42a3dc33d2b1e1 --- /dev/null +++ b/homeassistant/components/sunweg/sensor.py @@ -0,0 +1,177 @@ +"""Read status of SunWEG inverters.""" +from __future__ import annotations + +import logging +from types import MappingProxyType +from typing import Any + +from sunweg.api import APIHelper +from sunweg.device import Inverter +from sunweg.plant import Plant + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SunWEGData +from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType +from .sensor_types.inverter import INVERTER_SENSOR_TYPES +from .sensor_types.phase import PHASE_SENSOR_TYPES +from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription +from .sensor_types.string import STRING_SENSOR_TYPES +from .sensor_types.total import TOTAL_SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + + +def get_device_list( + api: APIHelper, config: MappingProxyType[str, Any] +) -> tuple[list[Inverter], int]: + """Retrieve the device list for the selected plant.""" + plant_id = int(config[CONF_PLANT_ID]) + + if plant_id == DEFAULT_PLANT_ID: + plant_info: list[Plant] = api.listPlants() + plant_id = plant_info[0].id + + devices: list[Inverter] = [] + # Get a list of devices for specified plant to add sensors for. + for inverter in api.plant(plant_id).inverters: + api.complete_inverter(inverter) + devices.append(inverter) + return (devices, plant_id) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the SunWEG sensor.""" + name = config_entry.data[CONF_NAME] + + probe: SunWEGData = hass.data[DOMAIN][config_entry.entry_id] + + devices, plant_id = await hass.async_add_executor_job( + get_device_list, probe.api, config_entry.data + ) + + entities = [ + SunWEGInverter( + probe, + name=f"{name} Total", + unique_id=f"{plant_id}-{description.key}", + description=description, + device_type=DeviceType.TOTAL, + ) + for description in TOTAL_SENSOR_TYPES + ] + + # Add sensors for each device in the specified plant. + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name}", + unique_id=f"{device.sn}-{description.key}", + description=description, + device_type=DeviceType.INVERTER, + inverter_id=device.id, + ) + for device in devices + for description in INVERTER_SENSOR_TYPES + ] + ) + + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name} {phase.name}", + unique_id=f"{device.sn}-{phase.name}-{description.key}", + description=description, + inverter_id=device.id, + device_type=DeviceType.PHASE, + deep_name=phase.name, + ) + for device in devices + for phase in device.phases + for description in PHASE_SENSOR_TYPES + ] + ) + + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name} {string.name}", + unique_id=f"{device.sn}-{string.name}-{description.key}", + description=description, + inverter_id=device.id, + device_type=DeviceType.STRING, + deep_name=string.name, + ) + for device in devices + for mppt in device.mppts + for string in mppt.strings + for description in STRING_SENSOR_TYPES + ] + ) + + async_add_entities(entities, True) + + +class SunWEGInverter(SensorEntity): + """Representation of a SunWEG Sensor.""" + + entity_description: SunWEGSensorEntityDescription + + def __init__( + self, + probe: SunWEGData, + name: str, + unique_id: str, + description: SunWEGSensorEntityDescription, + device_type: DeviceType, + inverter_id: int = 0, + deep_name: str | None = None, + ) -> None: + """Initialize a sensor.""" + self.probe = probe + self.entity_description = description + self.device_type = device_type + self.inverter_id = inverter_id + self.deep_name = deep_name + + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id + self._attr_icon = ( + description.icon if description.icon is not None else "mdi:solar-power" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(probe.plant_id))}, + manufacturer="SunWEG", + name=name, + ) + + def update(self) -> None: + """Get the latest data from the Sun WEG API and updates the state.""" + self.probe.update() + ( + self._attr_native_value, + self._attr_native_unit_of_measurement, + ) = self.probe.get_data( + api_variable_key=self.entity_description.api_variable_key, + api_variable_unit=self.entity_description.api_variable_unit, + deep_name=self.deep_name, + device_type=self.device_type, + inverter_id=self.inverter_id, + name=self.entity_description.name, + native_unit_of_measurement=self.native_unit_of_measurement, + never_resets=self.entity_description.never_resets, + previous_value_drop_threshold=self.entity_description.previous_value_drop_threshold, + ) diff --git a/homeassistant/components/sunweg/sensor_types/__init__.py b/homeassistant/components/sunweg/sensor_types/__init__.py new file mode 100644 index 00000000000000..f370fddd16b114 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/__init__.py @@ -0,0 +1 @@ +"""Sensor types for supported Sun WEG systems.""" diff --git a/homeassistant/components/sunweg/sensor_types/inverter.py b/homeassistant/components/sunweg/sensor_types/inverter.py new file mode 100644 index 00000000000000..f406efb1a8398f --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/inverter.py @@ -0,0 +1,69 @@ +"""SunWEG Sensor definitions for the Inverter type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) + +from .sensor_entity_description import SunWEGSensorEntityDescription + +INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="inverter_energy_today", + name="Energy today", + api_variable_key="_today_energy", + api_variable_unit="_today_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_energy_total", + name="Lifetime energy output", + api_variable_key="_total_energy", + api_variable_unit="_total_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + SunWEGSensorEntityDescription( + key="inverter_frequency", + name="AC frequency", + api_variable_key="_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_current_wattage", + name="Output power", + api_variable_key="_power", + api_variable_unit="_power_metric", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_temperature", + name="Temperature", + api_variable_key="_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:temperature-celsius", + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_power_factor", + name="Power Factor", + api_variable_key="_power_factor", + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/phase.py b/homeassistant/components/sunweg/sensor_types/phase.py new file mode 100644 index 00000000000000..ca6b9374e0db3e --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/phase.py @@ -0,0 +1,26 @@ +"""SunWEG Sensor definitions for the Phase type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential + +from .sensor_entity_description import SunWEGSensorEntityDescription + +PHASE_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="voltage", + name="Voltage", + api_variable_key="_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="amperage", + name="Amperage", + api_variable_key="_amperage", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py new file mode 100644 index 00000000000000..a47818b694b8fe --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py @@ -0,0 +1,23 @@ +"""Sensor Entity Description for the SunWEG integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass(frozen=True) +class SunWEGRequiredKeysMixin: + """Mixin for required keys.""" + + api_variable_key: str + + +@dataclass(frozen=True) +class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): + """Describes SunWEG sensor entity.""" + + api_variable_unit: str | None = None + previous_value_drop_threshold: float | None = None + never_resets: bool = False + icon: str | None = None diff --git a/homeassistant/components/sunweg/sensor_types/string.py b/homeassistant/components/sunweg/sensor_types/string.py new file mode 100644 index 00000000000000..d3ee0a43c21b86 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/string.py @@ -0,0 +1,26 @@ +"""SunWEG Sensor definitions for the String type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential + +from .sensor_entity_description import SunWEGSensorEntityDescription + +STRING_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="voltage", + name="Voltage", + api_variable_key="_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="amperage", + name="Amperage", + api_variable_key="_amperage", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/total.py b/homeassistant/components/sunweg/sensor_types/total.py new file mode 100644 index 00000000000000..ed9d6171735de7 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/total.py @@ -0,0 +1,54 @@ +"""SunWEG Sensor definitions for Totals.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import UnitOfEnergy, UnitOfPower + +from .sensor_entity_description import SunWEGSensorEntityDescription + +TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="total_money_total", + name="Money lifetime", + api_variable_key="_saving", + icon="mdi:cash", + native_unit_of_measurement="R$", + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="total_energy_today", + name="Energy Today", + api_variable_key="_today_energy", + api_variable_unit="_today_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SunWEGSensorEntityDescription( + key="total_output_power", + name="Output Power", + api_variable_key="_total_power", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + SunWEGSensorEntityDescription( + key="total_energy_output", + name="Lifetime energy output", + api_variable_key="_total_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + SunWEGSensorEntityDescription( + key="kwh_per_kwp", + name="kWh por kWp", + api_variable_key="_kwh_per_kwp", + ), + SunWEGSensorEntityDescription( + key="last_update", + name="Last Update", + api_variable_key="_last_update", + device_class=SensorDeviceClass.DATE, + ), +) diff --git a/homeassistant/components/sunweg/strings.json b/homeassistant/components/sunweg/strings.json new file mode 100644 index 00000000000000..3a910e6294007b --- /dev/null +++ b/homeassistant/components/sunweg/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "no_plants": "No plants have been found on this account" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plant" + }, + "title": "Select your plant" + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "title": "Enter your Sun WEG information" + } + } + } +} diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index 7c4509259ad517..38bed2e20a94ff 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -118,6 +118,7 @@ async def async_step_reauth_confirm( 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/manifest.json b/homeassistant/components/surepetcare/manifest.json index 89e018b6635ca5..bcfd10d2f0208b 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/surepetcare", "iot_class": "cloud_polling", "loggers": ["rich", "surepy"], - "requirements": ["surepy==0.8.0"] + "requirements": ["surepy==0.9.0"] } diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json index 2d297cc829e507..c3b7864f36ae2a 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/swepco/__init__.py b/homeassistant/components/swepco/__init__.py new file mode 100644 index 00000000000000..6a1bcc0209aec9 --- /dev/null +++ b/homeassistant/components/swepco/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Southwestern Electric Power Company (SWEPCO).""" diff --git a/homeassistant/components/swepco/manifest.json b/homeassistant/components/swepco/manifest.json new file mode 100644 index 00000000000000..115060b7e3f656 --- /dev/null +++ b/homeassistant/components/swepco/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "swepco", + "name": "Southwestern Electric Power Company (SWEPCO)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index c53cb1f6934b2c..9e01a07416f912 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -1 +1,67 @@ """The swiss_public_transport component.""" +import logging + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) + +from homeassistant import config_entries, core +from homeassistant.const import Platform +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_DESTINATION, CONF_START, DOMAIN +from .coordinator import SwissPublicTransportDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up Swiss public transport from a config entry.""" + config = entry.data + + start = config[CONF_START] + destination = config[CONF_DESTINATION] + + session = async_get_clientsession(hass) + opendata = OpendataTransport(start, destination, session) + + try: + await opendata.async_get_data() + except OpendataTransportConnectionError as e: + raise ConfigEntryNotReady( + f"Timeout while connecting for entry '{start} {destination}'" + ) from e + except OpendataTransportError as e: + _LOGGER.error( + "Setup failed for entry '%s %s', check at http://transport.opendata.ch/examples/stationboard.html if your station names are valid", + start, + destination, + ) + raise ConfigEntryError( + f"Setup failed for entry '{start} {destination}' with invalid data" + ) from e + + coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata) + 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: core.HomeAssistant, entry: config_entries.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/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py new file mode 100644 index 00000000000000..63eca1efe96a17 --- /dev/null +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -0,0 +1,104 @@ +"""Config flow for swiss_public_transport.""" +import logging +from typing import Any + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_DESTINATION, CONF_START, DOMAIN, PLACEHOLDERS + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_START): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Swiss public transport config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Async user step to set up the connection.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_START: user_input[CONF_START], + CONF_DESTINATION: user_input[CONF_DESTINATION], + } + ) + + session = async_get_clientsession(self.hass) + opendata = OpendataTransport( + user_input[CONF_START], user_input[CONF_DESTINATION], session + ) + try: + await opendata.async_get_data() + except OpendataTransportConnectionError: + errors["base"] = "cannot_connect" + except OpendataTransportError: + errors["base"] = "bad_config" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + description_placeholders=PLACEHOLDERS, + ) + + async def async_step_import(self, import_input: dict[str, Any]) -> FlowResult: + """Async import step to set up the connection.""" + self._async_abort_entries_match( + { + CONF_START: import_input[CONF_START], + CONF_DESTINATION: import_input[CONF_DESTINATION], + } + ) + + session = async_get_clientsession(self.hass) + opendata = OpendataTransport( + import_input[CONF_START], import_input[CONF_DESTINATION], session + ) + try: + await opendata.async_get_data() + except OpendataTransportConnectionError: + return self.async_abort(reason="cannot_connect") + except OpendataTransportError: + return self.async_abort(reason="bad_config") + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Unknown error raised by python-opendata-transport for '%s %s', check at http://transport.opendata.ch/examples/stationboard.html if your station names and your parameters are valid", + import_input[CONF_START], + import_input[CONF_DESTINATION], + ) + return self.async_abort(reason="unknown") + + return self.async_create_entry( + title=import_input[CONF_NAME], + data=import_input, + ) diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py new file mode 100644 index 00000000000000..6d9fb8bb9600ef --- /dev/null +++ b/homeassistant/components/swiss_public_transport/const.py @@ -0,0 +1,14 @@ +"""Constants for the swiss_public_transport integration.""" + +DOMAIN = "swiss_public_transport" + +CONF_DESTINATION = "to" +CONF_START = "from" + +DEFAULT_NAME = "Next Destination" + + +PLACEHOLDERS = { + "stationboard_url": "http://transport.opendata.ch/examples/stationboard.html", + "opendata_url": "http://transport.opendata.ch", +} diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py new file mode 100644 index 00000000000000..93b3312b09916d --- /dev/null +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -0,0 +1,81 @@ +"""DataUpdateCoordinator for the swiss_public_transport integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TypedDict + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import OpendataTransportError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DataConnection(TypedDict): + """A connection data class.""" + + departure: str + next_departure: str + next_on_departure: str + duration: str + platform: str + remaining_time: str + start: str + destination: str + train_number: str + transfers: str + delay: int + + +class SwissPublicTransportDataUpdateCoordinator(DataUpdateCoordinator[DataConnection]): + """A SwissPublicTransport Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, opendata: OpendataTransport) -> None: + """Initialize the SwissPublicTransport data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=90), + ) + self._opendata = opendata + + async def _async_update_data(self) -> DataConnection: + try: + await self._opendata.async_get_data() + except OpendataTransportError as e: + _LOGGER.warning( + "Unable to connect and retrieve data from transport.opendata.ch" + ) + raise UpdateFailed from e + + departure_time = dt_util.parse_datetime( + self._opendata.connections[0]["departure"] + ) + if departure_time: + remaining_time = departure_time - dt_util.as_local(dt_util.utcnow()) + else: + remaining_time = None + + return DataConnection( + departure=self._opendata.connections[0]["departure"], + next_departure=self._opendata.connections[1]["departure"], + next_on_departure=self._opendata.connections[2]["departure"], + train_number=self._opendata.connections[0]["number"], + platform=self._opendata.connections[0]["platform"], + transfers=self._opendata.connections[0]["transfers"], + duration=self._opendata.connections[0]["duration"], + start=self._opendata.from_name, + destination=self._opendata.to_name, + remaining_time=f"{remaining_time}", + delay=self._opendata.connections[0]["delay"], + ) diff --git a/homeassistant/components/swiss_public_transport/manifest.json b/homeassistant/components/swiss_public_transport/manifest.json index fd9908bffeb407..6f8e603bbe7626 100644 --- a/homeassistant/components/swiss_public_transport/manifest.json +++ b/homeassistant/components/swiss_public_transport/manifest.json @@ -1,9 +1,10 @@ { "domain": "swiss_public_transport", "name": "Swiss public transport", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@miaucl"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/swiss_public_transport", "iot_class": "cloud_polling", "loggers": ["opendata_transport"], - "requirements": ["python-opendata-transport==0.3.0"] + "requirements": ["python-opendata-transport==0.4.0"] } diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 12007e1741c41c..5d4a6813d2d90f 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -3,38 +3,27 @@ from datetime import timedelta import logging +from typing import TYPE_CHECKING -from opendata_transport import OpendataTransport -from opendata_transport.exceptions import OpendataTransportError import voluptuous as vol +from homeassistant import config_entries, core from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -_LOGGER = logging.getLogger(__name__) - -ATTR_DEPARTURE_TIME1 = "next_departure" -ATTR_DEPARTURE_TIME2 = "next_on_departure" -ATTR_DURATION = "duration" -ATTR_PLATFORM = "platform" -ATTR_REMAINING_TIME = "remaining_time" -ATTR_START = "start" -ATTR_TARGET = "destination" -ATTR_TRAIN_NUMBER = "train_number" -ATTR_TRANSFERS = "transfers" -ATTR_DELAY = "delay" - -CONF_DESTINATION = "to" -CONF_START = "from" - -DEFAULT_NAME = "Next Departure" +from .const import CONF_DESTINATION, CONF_START, DEFAULT_NAME, DOMAIN, PLACEHOLDERS +from .coordinator import SwissPublicTransportDataUpdateCoordinator +_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=90) @@ -47,89 +36,103 @@ ) +async def async_setup_entry( + hass: core.HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor from a config entry created in the integrations UI.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + unique_id = config_entry.unique_id + + if TYPE_CHECKING: + assert unique_id + + async_add_entities( + [SwissPublicTransportSensor(coordinator, unique_id)], + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Swiss public transport sensor.""" - - name = config.get(CONF_NAME) - start = config.get(CONF_START) - destination = config.get(CONF_DESTINATION) - - session = async_get_clientsession(hass) - opendata = OpendataTransport(start, destination, session) - - try: - await opendata.async_get_data() - except OpendataTransportError: - _LOGGER.error( - "Check at http://transport.opendata.ch/examples/stationboard.html " - "if your station names are valid" + """Set up the sensor platform.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Swiss public transport", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=PLACEHOLDERS, ) - return - - async_add_entities([SwissPublicTransportSensor(opendata, start, destination, name)]) -class SwissPublicTransportSensor(SensorEntity): - """Implementation of an Swiss public transport sensor.""" +class SwissPublicTransportSensor( + CoordinatorEntity[SwissPublicTransportDataUpdateCoordinator], SensorEntity +): + """Implementation of a Swiss public transport sensor.""" _attr_attribution = "Data provided by transport.opendata.ch" _attr_icon = "mdi:bus" - - def __init__(self, opendata, start, destination, name): + _attr_has_entity_name = True + _attr_translation_key = "departure" + + def __init__( + self, + coordinator: SwissPublicTransportDataUpdateCoordinator, + unique_id: str, + ) -> None: """Initialize the sensor.""" - self._opendata = opendata - self._name = name - self._from = start - self._to = destination - self._remaining_time = "" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return ( - self._opendata.connections[0]["departure"] - if self._opendata is not None - else None + super().__init__(coordinator) + self._attr_unique_id = f"{unique_id}_departure" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Opendata.ch", + entry_type=DeviceEntryType.SERVICE, ) - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if self._opendata is None: - return - - self._remaining_time = dt_util.parse_datetime( - self._opendata.connections[0]["departure"] - ) - dt_util.as_local(dt_util.utcnow()) - - return { - ATTR_TRAIN_NUMBER: self._opendata.connections[0]["number"], - ATTR_PLATFORM: self._opendata.connections[0]["platform"], - ATTR_TRANSFERS: self._opendata.connections[0]["transfers"], - ATTR_DURATION: self._opendata.connections[0]["duration"], - ATTR_DEPARTURE_TIME1: self._opendata.connections[1]["departure"], - ATTR_DEPARTURE_TIME2: self._opendata.connections[2]["departure"], - ATTR_START: self._opendata.from_name, - ATTR_TARGET: self._opendata.to_name, - ATTR_REMAINING_TIME: f"{self._remaining_time}", - ATTR_DELAY: self._opendata.connections[0]["delay"], + @callback + def _handle_coordinator_update(self) -> None: + """Handle the state update and prepare the extra state attributes.""" + self._attr_extra_state_attributes = { + key: value + for key, value in self.coordinator.data.items() + if key not in {"departure"} } + return super()._handle_coordinator_update() - async def async_update(self) -> None: - """Get the latest data from opendata.ch and update the states.""" - - try: - if self._remaining_time.total_seconds() < 0: - await self._opendata.async_get_data() - except OpendataTransportError: - _LOGGER.error("Unable to retrieve data from transport.opendata.ch") + @property + def native_value(self) -> str: + """Return the state of the sensor.""" + return self.coordinator.data["departure"] diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json new file mode 100644 index 00000000000000..6d0eb53ad11df3 --- /dev/null +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "error": { + "cannot_connect": "Cannot connect to server", + "bad_config": "Request failed due to bad config: Check at [stationboard]({stationboard_url}) if your station names are valid", + "unknown": "An unknown error was raised by python-opendata-transport" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "Cannot connect to server", + "bad_config": "Request failed due to bad config: Check the [stationboard]({stationboard_url}) for valid stations.", + "unknown": "An unknown error was raised by python-opendata-transport" + }, + "step": { + "user": { + "data": { + "from": "Start station", + "to": "End station" + }, + "description": "Provide start and end station for your connection\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", + "title": "Swiss Public Transport" + } + } + }, + "entity": { + "sensor": { + "departure": { + "name": "Departure" + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The swiss public transport YAML configuration import cannot connect to server", + "description": "Configuring swiss public transport using YAML is being removed but there was an connection error importing your YAML configuration.\n\nMake sure your home assistant can reach the [opendata server]({opendata_url}). In case the server is down, try again later." + }, + "deprecated_yaml_import_issue_bad_config": { + "title": "The swiss public transport YAML configuration import request failed due to bad config", + "description": "Configuring swiss public transport using YAML is being removed but there was bad config imported in your YAML configuration.\n\nCheck the [stationboard]({stationboard_url}) for valid stations." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The swiss public transport YAML configuration import failed with unknown error raised by python-opendata-transport", + "description": "Configuring swiss public transport using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nCheck your configuration or have a look at the documentation of the integration." + } + } +} diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index bf3c3424142e04..a318f763fcb70b 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -1,10 +1,11 @@ """Component to interface with switches that can be controlled remotely.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum +from functools import partial import logging +from typing import TYPE_CHECKING import voluptuous as vol @@ -20,6 +21,11 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -27,6 +33,11 @@ from .const import DOMAIN +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -48,8 +59,16 @@ class SwitchDeviceClass(StrEnum): # DEVICE_CLASS* below are deprecated as of 2021.12 # use the SwitchDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in SwitchDeviceClass] -DEVICE_CLASS_OUTLET = SwitchDeviceClass.OUTLET.value -DEVICE_CLASS_SWITCH = SwitchDeviceClass.SWITCH.value +_DEPRECATED_DEVICE_CLASS_OUTLET = DeprecatedConstantEnum( + SwitchDeviceClass.OUTLET, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SWITCH = DeprecatedConstantEnum( + SwitchDeviceClass.SWITCH, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) # mypy: disallow-any-generics @@ -89,20 +108,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SwitchEntityDescription(ToggleEntityDescription): +class SwitchEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes switch entities.""" device_class: SwitchDeviceClass | None = None -class SwitchEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", +} + + +class SwitchEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for switch entities.""" entity_description: SwitchEntityDescription _attr_device_class: SwitchDeviceClass | None - @property + @cached_property def device_class(self) -> SwitchDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py index 8b6527eb49edcb..90f6b98589389c 100644 --- a/homeassistant/components/switch_as_x/config_flow.py +++ b/homeassistant/components/switch_as_x/config_flow.py @@ -22,6 +22,7 @@ selector.SelectOptionDict(value=Platform.LIGHT, label="Light"), selector.SelectOptionDict(value=Platform.LOCK, label="Lock"), selector.SelectOptionDict(value=Platform.SIREN, label="Siren"), + selector.SelectOptionDict(value=Platform.VALVE, label="Valve"), ] CONFIG_FLOW = { diff --git a/homeassistant/components/switch_as_x/valve.py b/homeassistant/components/switch_as_x/valve.py new file mode 100644 index 00000000000000..3a9fbc16247adf --- /dev/null +++ b/homeassistant/components/switch_as_x/valve.py @@ -0,0 +1,91 @@ +"""Valve support for switch entities.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import EventStateChangedData +from homeassistant.helpers.typing import EventType + +from .entity import BaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Valve Switch config entry.""" + registry = er.async_get(hass) + entity_id = er.async_validate_entity_id( + registry, config_entry.options[CONF_ENTITY_ID] + ) + + async_add_entities( + [ + ValveSwitch( + hass, + config_entry.title, + VALVE_DOMAIN, + entity_id, + config_entry.entry_id, + ) + ] + ) + + +class ValveSwitch(BaseEntity, ValveEntity): + """Represents a Switch as a Valve.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_reports_position = False + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + await self.hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self._switch_entity_id}, + blocking=True, + context=self._context, + ) + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + await self.hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: self._switch_entity_id}, + blocking=True, + context=self._context, + ) + + @callback + def async_state_changed_listener( + self, event: EventType[EventStateChangedData] | None = None + ) -> None: + """Handle child updates.""" + super().async_state_changed_listener(event) + if ( + not self.available + or (state := self.hass.states.get(self._switch_entity_id)) is None + ): + return + + self._attr_is_closed = state.state != STATE_ON diff --git a/homeassistant/components/switchbee/strings.json b/homeassistant/components/switchbee/strings.json index 2abeee6dd7e16a..858bda35c0fabd 100644 --- a/homeassistant/components/switchbee/strings.json +++ b/homeassistant/components/switchbee/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 SwitchBee device." } } }, diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 445920ad276f43..6bad3c251425e3 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -98,6 +98,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # connectable means we can make connections to the device connectable = switchbot_model in CONNECTABLE_SUPPORTED_MODEL_TYPES address: str = entry.data[CONF_ADDRESS] + + await switchbot.close_stale_connections_by_address(address) + ble_device = bluetooth.async_ble_device_from_address( hass, address.upper(), connectable ) @@ -106,7 +109,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find Switchbot {sensor_type} with address {address}" ) - await switchbot.close_stale_connections(ble_device) cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice) if cls is switchbot.SwitchbotLock: try: diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index e835a2f4acabc2..d3d84d2cd48d08 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.40.1"] + "requirements": ["PySwitchbot==0.43.0"] } diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 9a4e4fbe1969af..1539c81331ee22 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==1.2.1"] + "requirements": ["switchbot-api==1.3.0"] } diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 4303c885106d68..5a1b7c821d2758 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -30,7 +30,7 @@ from .utils import get_breeze_remote_manager -@dataclass +@dataclass(frozen=True) class SwitcherThermostatButtonDescriptionMixin: """Mixin to describe a Switcher Thermostat Button entity.""" @@ -38,7 +38,7 @@ class SwitcherThermostatButtonDescriptionMixin: supported: Callable[[SwitcherBreezeRemote], bool] -@dataclass +@dataclass(frozen=True) class SwitcherThermostatButtonEntityDescription( ButtonEntityDescription, SwitcherThermostatButtonDescriptionMixin ): diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 1f335aee4b9c64..27c6b416cb44bd 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -27,7 +27,7 @@ from .models import SynologyDSMData -@dataclass +@dataclass(frozen=True) class SynologyDSMBinarySensorEntityDescription( BinarySensorEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index d62f816b29e419..0e737c48eb69ec 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -24,14 +24,14 @@ LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SynologyDSMbuttonDescriptionMixin: """Mixin to describe a Synology DSM button entity.""" press_action: Callable[[SynoApi], Callable[[], Coroutine[Any, Any, None]]] -@dataclass +@dataclass(frozen=True) class SynologyDSMbuttonDescription( ButtonEntityDescription, SynologyDSMbuttonDescriptionMixin ): diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index b76699631cbb7a..187db9fbba882f 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -35,7 +35,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SynologyDSMCameraEntityDescription( CameraEntityDescription, SynologyDSMEntityDescription ): @@ -153,7 +153,9 @@ async def async_camera_image( 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/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 36eb37b7882c9f..ef2fc3dc12812d 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -84,7 +84,7 @@ def _user_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: def _ordered_shared_schema( - schema_input: dict[str, Any] + schema_input: dict[str, Any], ) -> dict[vol.Required | vol.Optional, Any]: return { vol.Required(CONF_USERNAME, default=schema_input.get(CONF_USERNAME, "")): str, diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index bb668e292ccff8..8d53284fee7037 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -18,14 +18,14 @@ _CoordinatorT = TypeVar("_CoordinatorT", bound=SynologyDSMUpdateCoordinator[Any]) -@dataclass +@dataclass(frozen=True) class SynologyDSMRequiredKeysMixin: """Mixin for required keys.""" api_key: str -@dataclass +@dataclass(frozen=True) class SynologyDSMEntityDescription(EntityDescription, SynologyDSMRequiredKeysMixin): """Generic Synology DSM entity description.""" diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 16db365f708c9e..3f30fe9b4e9cb7 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -153,8 +153,7 @@ async def _async_build_diskstations( 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/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 292986473265c8..76606303c93a59 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -39,7 +39,7 @@ from .models import SynologyDSMData -@dataclass +@dataclass(frozen=True) class SynologyDSMSensorEntityDescription( SensorEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f7ae9c9f238b05..4ed061195778b7 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -10,6 +10,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": "The hostname or IP address of your Synology NAS." } }, "2sa": { diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index 074a423c53dd68..77dc854fa3af69 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SynologyDSMSwitchEntityDescription( SwitchEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index c550b180553975..c66fc3c3d73d0c 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -19,7 +19,7 @@ from .models import SynologyDSMData -@dataclass +@dataclass(frozen=True) class SynologyDSMUpdateEntityEntityDescription( UpdateEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 511feeaf93ca6b..1d36c673eb634c 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -19,7 +19,7 @@ from .entity import SystemBridgeEntity -@dataclass +@dataclass(frozen=True) class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing System Bridge binary sensor entities.""" diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 1bc00aee4f51ef..17c43fa4d24bd9 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.9.5"], + "requirements": ["systembridgeconnector==3.10.0"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index e3fd2c14654fa1..35cc0e00809b28 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -42,7 +42,7 @@ PIXELS: Final = "px" -@dataclass +@dataclass(frozen=True) class SystemBridgeSensorEntityDescription(SensorEntityDescription): """Class describing System Bridge sensor entities.""" diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py index 5ab8ac9f9305d1..69dbb1f7952c9b 100644 --- a/homeassistant/components/systemmonitor/__init__.py +++ b/homeassistant/components/systemmonitor/__init__.py @@ -1 +1,25 @@ -"""The systemmonitor integration.""" +"""The System Monitor integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up System Monitor from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload System Monitor config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py new file mode 100644 index 00000000000000..6d9787a39f5d4d --- /dev/null +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -0,0 +1,143 @@ +"""Adds config flow for System Monitor.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import voluptuous as vol + +from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.util import slugify + +from .const import CONF_PROCESS, DOMAIN +from .util import get_all_running_processes + + +async def validate_sensor_setup( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate sensor input.""" + # Standard behavior is to merge the result with the options. + # In this case, we want to add a sub-item so we update the options directly. + sensors: dict[str, list] = handler.options.setdefault(SENSOR_DOMAIN, {}) + processes = sensors.setdefault(CONF_PROCESS, []) + previous_processes = processes.copy() + processes.clear() + processes.extend(user_input[CONF_PROCESS]) + + entity_registry = er.async_get(handler.parent_handler.hass) + for process in previous_processes: + if process not in processes and ( + entity_id := entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, slugify(f"process_{process}") + ) + ): + entity_registry.async_remove(entity_id) + + return {} + + +async def validate_import_sensor_setup( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate sensor input.""" + # Standard behavior is to merge the result with the options. + # In this case, we want to add a sub-item so we update the options directly. + sensors: dict[str, list] = handler.options.setdefault(SENSOR_DOMAIN, {}) + import_processes: list[str] = user_input["processes"] + processes = sensors.setdefault(CONF_PROCESS, []) + processes.extend(import_processes) + legacy_resources: list[str] = handler.options.setdefault("resources", []) + legacy_resources.extend(user_input["legacy_resources"]) + + async_create_issue( + handler.parent_handler.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "System Monitor", + }, + ) + return {} + + +async def get_sensor_setup_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Return process sensor setup schema.""" + hass = handler.parent_handler.hass + processes = list(await hass.async_add_executor_job(get_all_running_processes)) + return vol.Schema( + { + vol.Required(CONF_PROCESS): SelectSelector( + SelectSelectorConfig( + options=processes, + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + sort=True, + ) + ) + } + ) + + +async def get_suggested_value(handler: SchemaCommonFlowHandler) -> dict[str, Any]: + """Return suggested values for sensor setup.""" + sensors: dict[str, list] = handler.options.get(SENSOR_DOMAIN, {}) + processes: list[str] = sensors.get(CONF_PROCESS, []) + return {CONF_PROCESS: processes} + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep(schema=vol.Schema({})), + "import": SchemaFlowFormStep( + schema=vol.Schema({}), + validate_user_input=validate_import_sensor_setup, + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + get_sensor_setup_schema, + suggested_values=get_suggested_value, + validate_user_input=validate_sensor_setup, + ) +} + + +class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for System Monitor.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return "System Monitor" + + @callback + def async_create_entry(self, data: Mapping[str, Any], **kwargs: Any) -> FlowResult: + """Finish config flow and create a config entry.""" + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + return super().async_create_entry(data, **kwargs) diff --git a/homeassistant/components/systemmonitor/const.py b/homeassistant/components/systemmonitor/const.py new file mode 100644 index 00000000000000..c92647f9c8e784 --- /dev/null +++ b/homeassistant/components/systemmonitor/const.py @@ -0,0 +1,17 @@ +"""Constants for System Monitor.""" + +DOMAIN = "systemmonitor" + +CONF_INDEX = "index" +CONF_PROCESS = "process" + +NETWORK_TYPES = [ + "network_in", + "network_out", + "throughput_network_in", + "throughput_network_out", + "packets_in", + "packets_out", + "ipv4_address", + "ipv6_address", +] diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 3bcbc75d3b7906..213fa9cf6be8ff 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -1,9 +1,10 @@ { "domain": "systemmonitor", "name": "System Monitor", - "codeowners": [], + "codeowners": ["@gjohansson-ST"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil==5.9.6"] + "requirements": ["psutil==5.9.7"] } diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 4cfbdba40668bd..28929d07a7c8c7 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -15,26 +15,29 @@ import voluptuous as vol from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, PERCENTAGE, STATE_OFF, STATE_ON, + EntityCategory, UnitOfDataRate, UnitOfInformation, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -46,6 +49,9 @@ from homeassistant.util import slugify import homeassistant.util.dt as dt_util +from .const import CONF_PROCESS, DOMAIN, NETWORK_TYPES +from .util import get_all_disk_mounts, get_all_network_interfaces + _LOGGER = logging.getLogger(__name__) CONF_ARG = "arg" @@ -64,7 +70,7 @@ SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" -@dataclass +@dataclass(frozen=True) class SysMonitorSensorEntityDescription(SensorEntityDescription): """Description for System Monitor sensor entities.""" @@ -261,6 +267,17 @@ def check_required_arg(value: Any) -> Any: return value +def check_legacy_resource(resource: str, resources: set[str]) -> bool: + """Return True if legacy resource was configured.""" + # This function to check legacy resources can be removed + # once we are removing the import from YAML + if resource in resources: + _LOGGER.debug("Checking %s in %s returns True", resource, ", ".join(resources)) + return True + _LOGGER.debug("Checking %s in %s returns False", resource, ", ".join(resources)) + return False + + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_RESOURCES, default={CONF_TYPE: "disk_use"}): vol.All( @@ -334,39 +351,156 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the system monitor sensors.""" + processes = [ + resource[CONF_ARG] + for resource in config[CONF_RESOURCES] + if resource[CONF_TYPE] == "process" + ] + legacy_config: list[dict[str, str]] = config[CONF_RESOURCES] + resources = [] + for resource_conf in legacy_config: + if (_type := resource_conf[CONF_TYPE]).startswith("disk_"): + if (arg := resource_conf.get(CONF_ARG)) is None: + resources.append(f"{_type}_/") + continue + resources.append(f"{_type}_{arg}") + continue + resources.append(f"{_type}_{resource_conf.get(CONF_ARG, '')}") + _LOGGER.debug( + "Importing config with processes: %s, resources: %s", processes, resources + ) + + # With removal of the import also cleanup legacy_resources logic in setup_entry + # Also cleanup entry.options["resources"] which is only imported for legacy reasons + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"processes": processes, "legacy_resources": resources}, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up System Montor sensors based on a config entry.""" entities = [] sensor_registry: dict[tuple[str, str], SensorData] = {} + legacy_resources: set[str] = set(entry.options.get("resources", [])) + loaded_resources: set[str] = set() + disk_arguments = await hass.async_add_executor_job(get_all_disk_mounts) + network_arguments = await hass.async_add_executor_job(get_all_network_interfaces) + cpu_temperature = await hass.async_add_executor_job(_read_cpu_temperature) + + _LOGGER.debug("Setup from options %s", entry.options) + + for _type, sensor_description in SENSOR_TYPES.items(): + if _type.startswith("disk_"): + for argument in disk_arguments: + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + is_enabled = check_legacy_resource( + f"{_type}_{argument}", legacy_resources + ) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue - for resource in config[CONF_RESOURCES]: - type_ = resource[CONF_TYPE] - # Initialize the sensor argument if none was provided. - # For disk monitoring default to "/" (root) to prevent runtime errors, if argument was not specified. - if CONF_ARG not in resource: - argument = "" - if resource[CONF_TYPE].startswith("disk_"): - argument = "/" - else: - argument = resource[CONF_ARG] + if _type in NETWORK_TYPES: + for argument in network_arguments: + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + is_enabled = check_legacy_resource( + f"{_type}_{argument}", legacy_resources + ) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue # Verify if we can retrieve CPU / processor temperatures. # If not, do not create the entity and add a warning to the log - if ( - type_ == "processor_temperature" - and await hass.async_add_executor_job(_read_cpu_temperature) is None - ): + if _type == "processor_temperature" and cpu_temperature is None: _LOGGER.warning("Cannot read CPU / processor temperature information") continue - sensor_registry[(type_, argument)] = SensorData( - argument, None, None, None, None - ) + if _type == "process": + _entry: dict[str, list] = entry.options.get(SENSOR_DOMAIN, {}) + for argument in _entry.get(CONF_PROCESS, []): + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + argument, + True, + ) + ) + continue + + sensor_registry[(_type, "")] = SensorData("", None, None, None, None) + is_enabled = check_legacy_resource(f"{_type}_", legacy_resources) + loaded_resources.add(f"{_type}_") entities.append( - SystemMonitorSensor(sensor_registry, SENSOR_TYPES[type_], argument) + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + "", + is_enabled, + ) ) - scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) + # Ensure legacy imported disk_* resources are loaded if they are not part + # of mount points automatically discovered + for resource in legacy_resources: + if resource.startswith("disk_"): + _LOGGER.debug( + "Check resource %s already loaded in %s", resource, loaded_resources + ) + if resource not in loaded_resources: + split_index = resource.rfind("_") + _type = resource[:split_index] + argument = resource[split_index + 1 :] + _LOGGER.debug("Loading legacy %s with argument %s", _type, argument) + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + entities.append( + SystemMonitorSensor( + sensor_registry, + SENSOR_TYPES[_type], + entry.entry_id, + argument, + True, + ) + ) + scan_interval = DEFAULT_SCAN_INTERVAL + await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) async_add_entities(entities) @@ -433,12 +567,16 @@ class SystemMonitorSensor(SensorEntity): """Implementation of a system monitor sensor.""" should_poll = False + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, sensor_registry: dict[tuple[str, str], SensorData], sensor_description: SysMonitorSensorEntityDescription, + entry_id: str, argument: str = "", + legacy_enabled: bool = False, ) -> None: """Initialize the sensor.""" self.entity_description = sensor_description @@ -446,6 +584,13 @@ def __init__( self._attr_unique_id: str = slugify(f"{sensor_description.key}_{argument}") self._sensor_registry = sensor_registry self._argument: str = argument + self._attr_entity_registry_enabled_default = legacy_enabled + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="System Monitor", + name="System Monitor", + ) @property def native_value(self) -> str | datetime | None: diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json new file mode 100644 index 00000000000000..88ecad4b107745 --- /dev/null +++ b/homeassistant/components/systemmonitor/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "step": { + "user": { + "description": "Press submit for initial setup. On the created config entry, press configure to add sensors for selected processes" + } + } + }, + "options": { + "step": { + "init": { + "description": "Configure a monitoring sensor for a running process", + "data": { + "process": "Processes to add as sensor(s)" + }, + "data_description": { + "process": "Select a running process from the list or add a custom value. Multiple selections/custom values are supported" + } + } + } + } +} diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py new file mode 100644 index 00000000000000..25b8aa2eb1d61d --- /dev/null +++ b/homeassistant/components/systemmonitor/util.py @@ -0,0 +1,50 @@ +"""Utils for System Monitor.""" + +import logging +import os + +import psutil + +_LOGGER = logging.getLogger(__name__) + + +def get_all_disk_mounts() -> set[str]: + """Return all disk mount points on system.""" + disks: set[str] = set() + for part in psutil.disk_partitions(all=True): + if os.name == "nt": + if "cdrom" in part.opts or part.fstype == "": + # skip cd-rom drives with no disk in it; they may raise + # ENOENT, pop-up a Windows GUI error for a non-ready + # partition or just hang. + continue + try: + usage = psutil.disk_usage(part.mountpoint) + except PermissionError: + _LOGGER.debug( + "No permission for running user to access %s", part.mountpoint + ) + continue + if usage.total > 0 and part.device != "": + disks.add(part.mountpoint) + _LOGGER.debug("Adding disks: %s", ", ".join(disks)) + return disks + + +def get_all_network_interfaces() -> set[str]: + """Return all network interfaces on system.""" + interfaces: set[str] = set() + for interface, _ in psutil.net_if_addrs().items(): + interfaces.add(interface) + _LOGGER.debug("Adding interfaces: %s", ", ".join(interfaces)) + return interfaces + + +def get_all_running_processes() -> set[str]: + """Return all running processes on system.""" + processes: set[str] = set() + for proc in psutil.process_iter(["name"]): + if proc.name() not in processes: + processes.add(proc.name()) + _LOGGER.debug("Running processes: %s", ", ".join(processes)) + return processes diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 1cd21634c8e174..7f166ccf01acbe 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -26,9 +26,11 @@ DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, PRESET_AUTO, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, SIGNAL_TADO_UPDATE_RECEIVED, TEMP_OFFSET, UPDATE_LISTENER, + UPDATE_MOBILE_DEVICE_TRACK, UPDATE_TRACK, ) @@ -38,12 +40,14 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.WATER_HEATER, ] MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) SCAN_INTERVAL = timedelta(minutes=5) +SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -85,12 +89,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: SCAN_INTERVAL, ) + update_mobile_devices = async_track_time_interval( + hass, + lambda now: tadoconnector.update_mobile_devices(), + SCAN_MOBILE_DEVICE_INTERVAL, + ) + update_listener = entry.add_update_listener(_async_update_listener) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { DATA: tadoconnector, UPDATE_TRACK: update_track, + UPDATE_MOBILE_DEVICE_TRACK: update_mobile_devices, UPDATE_LISTENER: update_listener, } @@ -127,6 +138,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id][UPDATE_TRACK]() hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER]() + hass.data[DOMAIN][entry.entry_id][UPDATE_MOBILE_DEVICE_TRACK]() if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) @@ -151,6 +163,7 @@ def __init__(self, hass, username, password, fallback): self.devices = None self.data = { "device": {}, + "mobile_device": {}, "weather": {}, "geofence": {}, "zone": {}, @@ -164,14 +177,17 @@ def fallback(self): def setup(self): """Connect to Tado and fetch the zones.""" self.tado = Tado(self._username, self._password) - self.tado.setDebugging(True) # Load zones and devices - self.zones = self.tado.getZones() - self.devices = self.tado.getDevices() - tado_home = self.tado.getMe()["homes"][0] + self.zones = self.tado.get_zones() + self.devices = self.tado.get_devices() + tado_home = self.tado.get_me()["homes"][0] self.home_id = tado_home["id"] self.home_name = tado_home["name"] + def get_mobile_devices(self): + """Return the Tado mobile devices.""" + return self.tado.getMobileDevices() + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update the registered zones.""" @@ -179,9 +195,35 @@ def update(self): self.update_zones() self.update_home() + def update_mobile_devices(self) -> None: + """Update the mobile devices.""" + try: + mobile_devices = self.get_mobile_devices() + except RuntimeError: + _LOGGER.error("Unable to connect to Tado while updating mobile devices") + return + + for mobile_device in mobile_devices: + self.data["mobile_device"][mobile_device["id"]] = mobile_device + + _LOGGER.debug( + "Dispatching update to %s mobile devices: %s", + self.home_id, + mobile_devices, + ) + dispatcher_send( + self.hass, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + ) + def update_devices(self): """Update the device data from Tado.""" - devices = self.tado.getDevices() + try: + devices = self.tado.get_devices() + except RuntimeError: + _LOGGER.error("Unable to connect to Tado while updating devices") + return + for device in devices: device_short_serial_no = device["shortSerialNo"] _LOGGER.debug("Updating device %s", device_short_serial_no) @@ -190,7 +232,7 @@ def update_devices(self): INSIDE_TEMPERATURE_MEASUREMENT in device["characteristics"]["capabilities"] ): - device[TEMP_OFFSET] = self.tado.getDeviceInfo( + device[TEMP_OFFSET] = self.tado.get_device_info( device_short_serial_no, TEMP_OFFSET ) except RuntimeError: @@ -218,7 +260,7 @@ def update_devices(self): def update_zones(self): """Update the zone data from Tado.""" try: - zone_states = self.tado.getZoneStates()["zoneStates"] + zone_states = self.tado.get_zone_states()["zoneStates"] except RuntimeError: _LOGGER.error("Unable to connect to Tado while updating zones") return @@ -230,7 +272,7 @@ def update_zone(self, zone_id): """Update the internal data from Tado.""" _LOGGER.debug("Updating zone %s", zone_id) try: - data = self.tado.getZoneState(zone_id) + data = self.tado.get_zone_state(zone_id) except RuntimeError: _LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id) return @@ -251,8 +293,8 @@ def update_zone(self, zone_id): def update_home(self): """Update the home data from Tado.""" try: - self.data["weather"] = self.tado.getWeather() - self.data["geofence"] = self.tado.getHomeState() + self.data["weather"] = self.tado.get_weather() + self.data["geofence"] = self.tado.get_home_state() dispatcher_send( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"), @@ -265,15 +307,15 @@ def update_home(self): def get_capabilities(self, zone_id): """Return the capabilities of the devices.""" - return self.tado.getCapabilities(zone_id) + return self.tado.get_capabilities(zone_id) def get_auto_geofencing_supported(self): """Return whether the Tado Home supports auto geofencing.""" - return self.tado.getAutoGeofencingSupported() + return self.tado.get_auto_geofencing_supported() def reset_zone_overlay(self, zone_id): """Reset the zone back to the default operation.""" - self.tado.resetZoneOverlay(zone_id) + self.tado.reset_zone_overlay(zone_id) self.update_zone(zone_id) def set_presence( @@ -282,11 +324,11 @@ def set_presence( ): """Set the presence to home, away or auto.""" if presence == PRESET_AWAY: - self.tado.setAway() + self.tado.set_away() elif presence == PRESET_HOME: - self.tado.setHome() + self.tado.set_home() elif presence == PRESET_AUTO: - self.tado.setAuto() + self.tado.set_auto() # Update everything when changing modes self.update_zones() @@ -320,7 +362,7 @@ def set_zone_overlay( ) try: - self.tado.setZoneOverlay( + self.tado.set_zone_overlay( zone_id, overlay_mode, temperature, @@ -328,7 +370,7 @@ def set_zone_overlay( device_type, "ON", mode, - fanSpeed=fan_speed, + fan_speed=fan_speed, swing=swing, ) @@ -340,7 +382,7 @@ def set_zone_overlay( def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): """Set a zone to off.""" try: - self.tado.setZoneOverlay( + self.tado.set_zone_overlay( zone_id, overlay_mode, None, None, device_type, "OFF" ) except RequestException as exc: @@ -351,6 +393,6 @@ def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): def set_temperature_offset(self, device_id, offset): """Set temperature offset of device.""" try: - self.tado.setTempOffset(device_id, offset) + self.tado.set_temp_offset(device_id, offset) except RequestException as exc: _LOGGER.error("Could not set temperature offset: %s", exc) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index c5222112c0281a..0f7a1b2b307afd 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -32,14 +32,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TadoBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Any], bool] -@dataclass +@dataclass(frozen=True) class TadoBinarySensorEntityDescription( BinarySensorEntityDescription, TadoBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index a755622ea76f2c..f9f4f80bde1663 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -4,6 +4,7 @@ import logging from typing import Any +import PyTado from PyTado.interface import Tado import requests.exceptions import voluptuous as vol @@ -16,6 +17,7 @@ from .const import ( CONF_FALLBACK, + CONF_HOME_ID, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_OPTIONS, DOMAIN, @@ -110,6 +112,47 @@ async def async_step_homekit( self._abort_if_unique_id_configured() return await self.async_step_user() + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + _LOGGER.debug("Importing Tado from configuration.yaml") + username = import_config[CONF_USERNAME] + password = import_config[CONF_PASSWORD] + imported_home_id = import_config[CONF_HOME_ID] + + self._async_abort_entries_match( + { + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_HOME_ID: imported_home_id, + } + ) + + try: + validate_result = await validate_input( + self.hass, + { + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + except exceptions.HomeAssistantError: + return self.async_abort(reason="import_failed") + except PyTado.exceptions.TadoWrongCredentialsException: + return self.async_abort(reason="import_failed_invalid_auth") + + home_id = validate_result[UNIQUE_ID] + await self.async_set_unique_id(home_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=import_config[CONF_USERNAME], + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_HOME_ID: home_id, + }, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index d6ae50c33c1d78..c14906c3a8966a 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -36,8 +36,10 @@ # Configuration CONF_FALLBACK = "fallback" +CONF_HOME_ID = "home_id" DATA = "data" UPDATE_TRACK = "update_track" +UPDATE_MOBILE_DEVICE_TRACK = "update_mobile_device_track" # Weather CONDITIONS_MAP = { @@ -177,6 +179,7 @@ DOMAIN = "tado" SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}_{}" +SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED = "tado_mobile_device_update_received" UNIQUE_ID = "unique_id" DEFAULT_NAME = "Tado" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 1365c9f23a30e4..9c50318639dab1 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -1,32 +1,30 @@ """Support for Tado Smart device trackers.""" from __future__ import annotations -import asyncio -from collections import namedtuple -from datetime import timedelta -from http import HTTPStatus import logging +from typing import Any -import aiohttp import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, DeviceScanner, + SourceType, + TrackerEntity, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_HOME, STATE_NOT_HOME +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType 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.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) - -CONF_HOME_ID = "home_id" +from .const import CONF_HOME_ID, DATA, DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) +_LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( { @@ -37,113 +35,168 @@ ) -def get_scanner(hass: HomeAssistant, config: ConfigType) -> TadoDeviceScanner | None: - """Return a Tado scanner.""" - scanner = TadoDeviceScanner(hass, config[DOMAIN]) - return scanner if scanner.success_init else None - - -Device = namedtuple("Device", ["mac", "name"]) - - -class TadoDeviceScanner(DeviceScanner): - """Scanner for geofenced devices from Tado.""" - - def __init__(self, hass, config): - """Initialize the scanner.""" - self.hass = hass - self.last_results = [] - - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] +async def async_get_scanner( + hass: HomeAssistant, config: ConfigType +) -> DeviceScanner | None: + """Configure the Tado device scanner.""" + device_config = config["device_tracker"] + import_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_USERNAME: device_config[CONF_USERNAME], + CONF_PASSWORD: device_config[CONF_PASSWORD], + CONF_HOME_ID: device_config.get(CONF_HOME_ID), + }, + ) + + translation_key = "deprecated_yaml_import_device_tracker" + if import_result.get("type") == FlowResultType.ABORT: + translation_key = "import_aborted" + if import_result.get("reason") == "import_failed": + translation_key = "import_failed" + if import_result.get("reason") == "import_failed_invalid_auth": + translation_key = "import_failed_invalid_auth" + + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_device_tracker", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + ) + return None + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tado device scannery entity.""" + _LOGGER.debug("Setting up Tado device scanner entity") + tado = hass.data[DOMAIN][entry.entry_id][DATA] + tracked: set = set() + + @callback + def update_devices() -> None: + """Update the values of the devices.""" + add_tracked_entities(hass, tado, async_add_entities, tracked) + + update_devices() + + entry.async_on_unload( + async_dispatcher_connect( + hass, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + update_devices, + ) + ) + + +@callback +def add_tracked_entities( + hass: HomeAssistant, + tado: Any, + async_add_entities: AddEntitiesCallback, + tracked: set[str], +) -> None: + """Add new tracker entities from Tado.""" + _LOGGER.debug("Fetching Tado devices from API") + new_tracked = [] + for device_key, device in tado.data["mobile_device"].items(): + if device_key in tracked: + continue - # The Tado device tracker can work with or without a home_id - self.home_id = config[CONF_HOME_ID] if CONF_HOME_ID in config else None + _LOGGER.debug( + "Adding Tado device %s with deviceID %s", device["name"], device_key + ) + new_tracked.append(TadoDeviceTrackerEntity(device_key, device["name"], tado)) + tracked.add(device_key) + + async_add_entities(new_tracked) + + +class TadoDeviceTrackerEntity(TrackerEntity): + """A Tado Device Tracker entity.""" + + _attr_should_poll = False + + def __init__( + self, + device_id: str, + device_name: str, + tado: Any, + ) -> None: + """Initialize a Tado Device Tracker entity.""" + super().__init__() + self._attr_unique_id = device_id + self._device_id = device_id + self._device_name = device_name + self._tado = tado + self._active = False + self._latitude = None + self._longitude = None + + @callback + def update_state(self) -> None: + """Update the Tado device.""" + _LOGGER.debug( + "Updating Tado mobile device: %s (ID: %s)", + self._device_name, + self._device_id, + ) + device = self._tado.data["mobile_device"][self._device_id] - # If there's a home_id, we need a different API URL - if self.home_id is None: - self.tadoapiurl = "https://my.tado.com/api/v2/me" + self._active = False + if device.get("location") is not None and device["location"]["atHome"]: + _LOGGER.debug("Tado device %s is at home", device["name"]) + self._active = True else: - self.tadoapiurl = "https://my.tado.com/api/v2/homes/{home_id}/mobileDevices" - - # The API URL always needs a username and password - self.tadoapiurl += "?username={username}&password={password}" - - self.websession = None - - self.success_init = asyncio.run_coroutine_threadsafe( - self._async_update_info(), hass.loop - ).result() - - _LOGGER.info("Scanner initialized") - - async def async_scan_devices(self): - """Scan for devices and return a list containing found device ids.""" - await self._async_update_info() - return [device.mac for device in self.last_results] - - async def async_get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - filter_named = [ - result.name for result in self.last_results if result.mac == device - ] - - if filter_named: - return filter_named[0] - return None - - @Throttle(MIN_TIME_BETWEEN_SCANS) - async def _async_update_info(self): - """Query Tado for device marked as at home. - - Returns boolean if scanning successful. - """ - _LOGGER.debug("Requesting Tado") - - if self.websession is None: - self.websession = async_create_clientsession( - self.hass, cookie_jar=aiohttp.CookieJar(unsafe=True) + _LOGGER.debug("Tado device %s is not at home", device["name"]) + + @callback + def on_demand_update(self) -> None: + """Update state on demand.""" + self.update_state() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register state update callback.""" + _LOGGER.debug("Registering Tado device tracker entity") + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + self.on_demand_update, ) + ) - last_results = [] - - try: - async with asyncio.timeout(10): - # Format the URL here, so we can log the template URL if - # anything goes wrong without exposing username and password. - url = self.tadoapiurl.format( - home_id=self.home_id, username=self.username, password=self.password - ) - - response = await self.websession.get(url) - - if response.status != HTTPStatus.OK: - _LOGGER.warning("Error %d on %s", response.status, self.tadoapiurl) - return False - - tado_json = await response.json() - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Cannot load Tado data") - return False + self.update_state() - # Without a home_id, we fetched an URL where the mobile devices can be - # found under the mobileDevices key. - if "mobileDevices" in tado_json: - tado_json = tado_json["mobileDevices"] + @property + def name(self) -> str: + """Return the name of the device.""" + return self._device_name - # Find devices that have geofencing enabled, and are currently at home. - for mobile_device in tado_json: - if mobile_device.get("location") and mobile_device["location"]["atHome"]: - device_id = mobile_device["id"] - device_name = mobile_device["name"] - last_results.append(Device(device_id, device_name)) + @property + def location_name(self) -> str: + """Return the state of the device.""" + return STATE_HOME if self._active else STATE_NOT_HOME - self.last_results = last_results + @property + def latitude(self) -> None: + """Return latitude value of the device.""" + return None - _LOGGER.debug( - "Tado presence query successful, %d device(s) at home", - len(self.last_results), - ) + @property + def longitude(self) -> None: + """Return longitude value of the device.""" + return None - return True + @property + def source_type(self) -> SourceType: + """Return the source type.""" + return SourceType.GPS diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 62f7a377239efb..bae637f31801cd 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -1,7 +1,7 @@ { "domain": "tado", "name": "Tado", - "codeowners": ["@michaelarnauts", "@chiefdragon"], + "codeowners": ["@michaelarnauts", "@chiefdragon", "@erwindouna"], "config_flow": true, "dhcp": [ { @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.15.0"] + "requirements": ["python-tado==0.17.3"] } diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index c665cc3c592f82..a9647c7e6e5c6b 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -35,14 +35,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TadoSensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Any], StateType] -@dataclass +@dataclass(frozen=True) class TadoSensorEntityDescription( SensorEntityDescription, TadoSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 9858b7aa51b629..d50d14905669d3 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -123,5 +123,23 @@ } } } + }, + "issues": { + "deprecated_yaml_import_device_tracker": { + "title": "Tado YAML device tracker configuration imported", + "description": "Configuring the Tado Device Tracker using YAML is being removed.\nRemove the YAML device tracker configuration and restart Home Assistant." + }, + "import_aborted": { + "title": "Import aborted", + "description": "Configuring the Tado Device Tracker using YAML is being removed.\n The import was aborted, due to an existing config entry being the same as the data being imported in the YAML. Remove the YAML device tracker configuration and restart Home Assistant. Please use the UI to configure Tado." + }, + "import_failed": { + "title": "Failed to import", + "description": "Failed to import the configuration for the Tado Device Tracker. Please use the UI to configure Tado. Don't forget to delete the YAML configuration." + }, + "import_failed_invalid_auth": { + "title": "Failed to import, invalid credentials", + "description": "Failed to import the configuration for the Tado Device Tracker, due to invalid credentials. Please fix the YAML configuration and restart Home Assistant. Alternatively you can use the UI to configure Tado. Don't forget to delete the YAML configuration, once the import is successful." + } } } diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index e82083f73ec13b..59b0fa995e4144 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.") + 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, DEVICE_ID: device_id}, context=context + EVENT_TAG_SCANNED, + {TAG_ID: tag_id, CONF_NAME: tag_name, DEVICE_ID: device_id}, + context=context, ) - helper = hass.data[DOMAIN][TAGS] + 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 ecc561f03554b7..00fa21279ea27e 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -20,20 +20,13 @@ from .const import DOMAIN -@dataclass -class TailscaleBinarySensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, 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 75dca4ed840780..5d2e615945b7f1 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -21,20 +21,13 @@ from .const import DOMAIN -@dataclass -class TailscaleSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, 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/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py new file mode 100644 index 00000000000000..f4772050e5a8d0 --- /dev/null +++ b/homeassistant/components/tailwind/__init__.py @@ -0,0 +1,44 @@ +"""Integration for Tailwind devices.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.BUTTON, Platform.NUMBER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tailwind device from a config entry.""" + coordinator = TailwindDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + # Register the Tailwind device, since other entities will have it as a parent. + # This prevents a child device being created before the parent ending up + # with a missing via_device. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, coordinator.data.device_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.data.mac_address)}, + manufacturer="Tailwind", + model=coordinator.data.product, + sw_version=coordinator.data.firmware_version, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Tailwind 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/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py new file mode 100644 index 00000000000000..eaa0cbd1a08de4 --- /dev/null +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -0,0 +1,67 @@ +"""Binary sensor entity platform for Tailwind.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from gotailwind import TailwindDoor + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindDoorEntity + + +@dataclass(kw_only=True, frozen=True) +class TailwindDoorBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Tailwind door binary sensor entities.""" + + is_on_fn: Callable[[TailwindDoor], bool] + + +DESCRIPTIONS: tuple[TailwindDoorBinarySensorEntityDescription, ...] = ( + TailwindDoorBinarySensorEntityDescription( + key="locked_out", + translation_key="operational_problem", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:garage-alert", + is_on_fn=lambda door: door.locked_out, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind binary sensor based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindDoorBinarySensorEntity(coordinator, door_id, description) + for description in DESCRIPTIONS + for door_id in coordinator.data.doors + ) + + +class TailwindDoorBinarySensorEntity(TailwindDoorEntity, BinarySensorEntity): + """Representation of a Tailwind door binary sensor entity.""" + + entity_description: TailwindDoorBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn( + self.coordinator.data.doors[self.door_id] + ) diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py new file mode 100644 index 00000000000000..019b803901cd94 --- /dev/null +++ b/homeassistant/components/tailwind/button.py @@ -0,0 +1,73 @@ +"""Button entity platform for Tailwind.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from gotailwind import Tailwind, TailwindError + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindEntity + + +@dataclass(frozen=True, kw_only=True) +class TailwindButtonEntityDescription(ButtonEntityDescription): + """Class describing Tailwind button entities.""" + + press_fn: Callable[[Tailwind], Awaitable[Any]] + + +DESCRIPTIONS = [ + TailwindButtonEntityDescription( + key="identify", + device_class=ButtonDeviceClass.IDENTIFY, + entity_category=EntityCategory.CONFIG, + press_fn=lambda tailwind: tailwind.identify(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind button based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindButtonEntity( + coordinator, + description, + ) + for description in DESCRIPTIONS + ) + + +class TailwindButtonEntity(TailwindEntity, ButtonEntity): + """Representation of a Tailwind button entity.""" + + entity_description: TailwindButtonEntityDescription + + async def async_press(self) -> None: + """Trigger button press on the Tailwind device.""" + try: + await self.entity_description.press_fn(self.coordinator.tailwind) + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py new file mode 100644 index 00000000000000..97515f17f3f2ca --- /dev/null +++ b/homeassistant/components/tailwind/config_flow.py @@ -0,0 +1,233 @@ +"""Config flow to configure the Tailwind integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from gotailwind import ( + MIN_REQUIRED_FIRMWARE_VERSION, + Tailwind, + TailwindAuthenticationError, + TailwindConnectionError, + TailwindUnsupportedFirmwareVersionError, + tailwind_device_id_to_mac_address, +) +import voluptuous as vol + +from homeassistant.components import zeroconf +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN, LOGGER + +LOCAL_CONTROL_KEY_URL = ( + "https://web.gotailwind.com/client/integration/local-control-key" +) + + +class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Tailwind config flow.""" + + VERSION = 1 + + host: str + reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + try: + return await self._async_step_create_entry( + host=user_input[CONF_HOST], + token=user_input[CONF_TOKEN], + ) + except AbortFlow: + raise + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors[CONF_HOST] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=user_input.get(CONF_HOST) + ): TextSelector(TextSelectorConfig(autocomplete="off")), + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery of a Tailwind device.""" + if not (device_id := discovery_info.properties.get("device_id")): + return self.async_abort(reason="no_device_id") + + if ( + version := discovery_info.properties.get("SW ver") + ) and version < MIN_REQUIRED_FIRMWARE_VERSION: + return self.async_abort(reason="unsupported_firmware") + + await self.async_set_unique_id( + format_mac(tailwind_device_id_to_mac_address(device_id)) + ) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self.host = discovery_info.host + self.context.update( + { + "title_placeholders": { + "name": f"Tailwind {discovery_info.properties.get('product')}" + }, + "configuration_url": LOCAL_CONTROL_KEY_URL, + } + ) + 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.""" + errors = {} + + if user_input is not None: + try: + return await self._async_step_create_entry( + host=self.host, + token=user_input[CONF_TOKEN], + ) + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + + async def async_step_reauth(self, _: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with a Tailwind device.""" + 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: + """Handle re-authentication with a Tailwind device.""" + errors = {} + + if user_input is not None and self.reauth_entry: + try: + return await self._async_step_create_entry( + host=self.reauth_entry.data[CONF_HOST], + token=user_input[CONF_TOKEN], + ) + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: + """Handle dhcp discovery to update existing entries. + + This flow is triggered only by DHCP discovery of known devices. + """ + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + # This situation should never happen, as Home Assistant will only + # send updates for existing entries. In case it does, we'll just + # abort the flow with an unknown error. + return self.async_abort(reason="unknown") + + async def _async_step_create_entry(self, *, host: str, token: str) -> FlowResult: + """Create entry.""" + tailwind = Tailwind( + host=host, token=token, session=async_get_clientsession(self.hass) + ) + + try: + status = await tailwind.status() + except TailwindUnsupportedFirmwareVersionError: + return self.async_abort(reason="unsupported_firmware") + + if self.reauth_entry: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={CONF_HOST: host, CONF_TOKEN: token}, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + await self.async_set_unique_id( + format_mac(status.mac_address), raise_on_progress=False + ) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: host, + CONF_TOKEN: token, + } + ) + + return self.async_create_entry( + title=f"Tailwind {status.product}", + data={CONF_HOST: host, CONF_TOKEN: token}, + ) diff --git a/homeassistant/components/tailwind/const.py b/homeassistant/components/tailwind/const.py new file mode 100644 index 00000000000000..99e5bb0f1bf985 --- /dev/null +++ b/homeassistant/components/tailwind/const.py @@ -0,0 +1,9 @@ +"""Constants for the Tailwind integration.""" +from __future__ import annotations + +import logging +from typing import Final + +DOMAIN: Final = "tailwind" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py new file mode 100644 index 00000000000000..d918b093605902 --- /dev/null +++ b/homeassistant/components/tailwind/coordinator.py @@ -0,0 +1,47 @@ +"""Data update coordinator for Tailwind.""" +from datetime import timedelta + +from gotailwind import ( + Tailwind, + TailwindAuthenticationError, + TailwindDeviceStatus, + TailwindError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]): + """Class to manage fetching Tailwind data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + self.tailwind = Tailwind( + host=entry.data[CONF_HOST], + token=entry.data[CONF_TOKEN], + session=async_get_clientsession(hass), + ) + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN}_{entry.data[CONF_HOST]}", + update_interval=timedelta(seconds=5), + ) + + async def _async_update_data(self) -> TailwindDeviceStatus: + """Fetch data from the Tailwind device.""" + try: + return await self.tailwind.status() + except TailwindAuthenticationError as err: + raise ConfigEntryAuthFailed from err + except TailwindError as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py new file mode 100644 index 00000000000000..935fa01eee08ef --- /dev/null +++ b/homeassistant/components/tailwind/cover.py @@ -0,0 +1,125 @@ +"""Cover entity platform for Tailwind.""" +from __future__ import annotations + +from typing import Any + +from gotailwind import ( + TailwindDoorDisabledError, + TailwindDoorLockedOutError, + TailwindDoorOperationCommand, + TailwindDoorState, + TailwindError, +) + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindDoorEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind cover based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindDoorCoverEntity(coordinator, door_id) + for door_id in coordinator.data.doors + ) + + +class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): + """Representation of a Tailwind door binary sensor entity.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_is_closing = False + _attr_is_opening = False + _attr_name = None + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + @property + def is_closed(self) -> bool: + """Return if the cover is closed or not.""" + return ( + self.coordinator.data.doors[self.door_id].state == TailwindDoorState.CLOSED + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the garage door. + + The Tailwind operating command will await the confirmation of the + door being opened before returning. + """ + self._attr_is_opening = True + self.async_write_ha_state() + try: + await self.coordinator.tailwind.operate( + door=self.coordinator.data.doors[self.door_id], + operation=TailwindDoorOperationCommand.OPEN, + ) + except TailwindDoorDisabledError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_disabled", + ) from exc + except TailwindDoorLockedOutError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_locked_out", + ) from exc + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc + finally: + self._attr_is_opening = False + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the garage door. + + The Tailwind operating command will await the confirmation of the + door being closed before returning. + """ + self._attr_is_closing = True + self.async_write_ha_state() + try: + await self.coordinator.tailwind.operate( + door=self.coordinator.data.doors[self.door_id], + operation=TailwindDoorOperationCommand.CLOSE, + ) + except TailwindDoorDisabledError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_disabled", + ) from exc + except TailwindDoorLockedOutError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_locked_out", + ) from exc + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc + self._attr_is_closing = False + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tailwind/diagnostics.py b/homeassistant/components/tailwind/diagnostics.py new file mode 100644 index 00000000000000..50c9b2266e2bbc --- /dev/null +++ b/homeassistant/components/tailwind/diagnostics.py @@ -0,0 +1,18 @@ +"""Diagnostics platform for Tailwind.""" +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 TailwindDataUpdateCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + return coordinator.data.to_dict() diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py new file mode 100644 index 00000000000000..843cc6005824ee --- /dev/null +++ b/homeassistant/components/tailwind/entity.py @@ -0,0 +1,64 @@ +"""Base entity for the Tailwind integration.""" +from __future__ import annotations + +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 TailwindDataUpdateCoordinator + + +class TailwindEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): + """Defines an Tailwind entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize an Tailwind entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.data.device_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.device_id)}, + ) + + +class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): + """Defines an Tailwind door entity. + + These are the entities that belong to a specific garage door opener + that is connected via the Tailwind controller. + """ + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + door_id: str, + entity_description: EntityDescription | None = None, + ) -> None: + """Initialize an Tailwind door entity.""" + super().__init__(coordinator) + self.door_id = door_id + + self._attr_unique_id = f"{coordinator.data.device_id}-{door_id}" + if entity_description: + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.data.device_id}-{door_id}-{entity_description.key}" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, + via_device=(DOMAIN, coordinator.data.device_id), + name=f"Door {coordinator.data.doors[door_id].index+1}", + manufacturer="Tailwind", + model=coordinator.data.product, + sw_version=coordinator.data.firmware_version, + ) diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json new file mode 100644 index 00000000000000..da115ab5603b3c --- /dev/null +++ b/homeassistant/components/tailwind/manifest.json @@ -0,0 +1,24 @@ +{ + "domain": "tailwind", + "name": "Tailwind", + "codeowners": ["@frenck"], + "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], + "documentation": "https://www.home-assistant.io/integrations/tailwind", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "platinum", + "requirements": ["gotailwind==0.2.2"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "properties": { + "vendor": "tailwind" + } + } + ] +} diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py new file mode 100644 index 00000000000000..5853e5c2d3063b --- /dev/null +++ b/homeassistant/components/tailwind/number.py @@ -0,0 +1,84 @@ +"""Number entity platform for Tailwind.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from gotailwind import Tailwind, TailwindDeviceStatus, TailwindError + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindEntity + + +@dataclass(frozen=True, kw_only=True) +class TailwindNumberEntityDescription(NumberEntityDescription): + """Class describing Tailwind number entities.""" + + value_fn: Callable[[TailwindDeviceStatus], int] + set_value_fn: Callable[[Tailwind, float], Awaitable[Any]] + + +DESCRIPTIONS = [ + TailwindNumberEntityDescription( + key="brightness", + icon="mdi:led-on", + translation_key="brightness", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: data.led_brightness, + set_value_fn=lambda tailwind, brightness: tailwind.status_led( + brightness=int(brightness), + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind number based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindNumberEntity( + coordinator, + description, + ) + for description in DESCRIPTIONS + ) + + +class TailwindNumberEntity(TailwindEntity, NumberEntity): + """Representation of a Tailwind number entity.""" + + entity_description: TailwindNumberEntityDescription + + @property + def native_value(self) -> int | None: + """Return the number value.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Change to new number value.""" + try: + await self.entity_description.set_value_fn(self.coordinator.tailwind, value) + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json new file mode 100644 index 00000000000000..7ff7fd439ccb88 --- /dev/null +++ b/homeassistant/components/tailwind/strings.json @@ -0,0 +1,75 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "description": "Reauthenticate with your Tailwind garage door opener.\n\nTo do so, you will need to get your new local control key of your Tailwind device. For more details, see the description below the field down below.", + "data": { + "token": "[%key:component::tailwind::config::step::user::data::token%]" + }, + "data_description": { + "token": "[%key:component::tailwind::config::step::user::data_description::token%]" + } + }, + "user": { + "description": "Set up your Tailwind garage door opener to integrate with Home Assistant.\n\nTo do so, you will need to get the local control key and IP address of your Tailwind device. For more details, see the description below the fields down below.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "token": "Local control key token" + }, + "data_description": { + "host": "The hostname or IP address of your Tailwind device. You can find the IP address by going into the Tailwind app and selecting your Tailwind device's cog icon. The IP address is shown in the **Device Info** section.", + "token": "To find local control key token, browse to the [Tailwind web portal]({url}), log in with your Tailwind account, and select the [**Local Control Key**]({url}) tab. The 6-digit number shown is your local control key token." + } + }, + "zeroconf_confirm": { + "description": "Set up your discovered Tailwind garage door opener to integrate with Home Assistant.\n\nTo do so, you will need to get the local control key of your Tailwind device. For more details, see the description below the field down below.", + "data": { + "token": "[%key:component::tailwind::config::step::user::data::token%]" + }, + "data_description": { + "token": "[%key:component::tailwind::config::step::user::data_description::token%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_device_id": "The discovered Tailwind device did not provide a device ID.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app." + } + }, + "entity": { + "binary_sensor": { + "operational_problem": { + "name": "Operational problem", + "state": { + "off": "Operational", + "on": "Locked out" + } + } + }, + "number": { + "brightness": { + "name": "Status LED brightness" + } + } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Tailwind device." + }, + "door_disabled": { + "message": "The door is disabled and cannot be operated." + }, + "door_locked_out": { + "message": "The door is locked out and cannot be operated." + } + } +} diff --git a/homeassistant/components/tami4/__init__.py b/homeassistant/components/tami4/__init__.py index 846f1194930848..643363b1285237 100644 --- a/homeassistant/components/tami4/__init__.py +++ b/homeassistant/components/tami4/__init__.py @@ -11,7 +11,7 @@ from .const import API, CONF_REFRESH_TOKEN, COORDINATOR, DOMAIN from .coordinator import Tami4EdgeWaterQualityCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tami4/button.py b/homeassistant/components/tami4/button.py new file mode 100644 index 00000000000000..c17a296e2190a6 --- /dev/null +++ b/homeassistant/components/tami4/button.py @@ -0,0 +1,54 @@ +"""Button entities for Tami4Edge.""" +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from Tami4EdgeAPI import Tami4EdgeAPI + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import API, DOMAIN +from .entity import Tami4EdgeBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class Tami4EdgeButtonEntityDescription(ButtonEntityDescription): + """A class that describes Tami4Edge button entities.""" + + press_fn: Callable[[Tami4EdgeAPI], None] + + +BUTTONS: tuple[Tami4EdgeButtonEntityDescription] = ( + Tami4EdgeButtonEntityDescription( + key="boil_water", + translation_key="boil_water", + icon="mdi:kettle-steam", + press_fn=lambda api: api.boil_water(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Perform the setup for Tami4Edge.""" + api: Tami4EdgeAPI = hass.data[DOMAIN][entry.entry_id][API] + + async_add_entities( + Tami4EdgeButton(api, entity_description) for entity_description in BUTTONS + ) + + +class Tami4EdgeButton(Tami4EdgeBaseEntity, ButtonEntity): + """Button entity for Tami4Edge.""" + + entity_description: Tami4EdgeButtonEntityDescription + + def press(self) -> None: + """Handle the button press.""" + self.entity_description.press_fn(self._api) diff --git a/homeassistant/components/tami4/strings.json b/homeassistant/components/tami4/strings.json index 9036d92d6f1a4c..79447d93e9e6b3 100644 --- a/homeassistant/components/tami4/strings.json +++ b/homeassistant/components/tami4/strings.json @@ -22,6 +22,11 @@ "filter_litters_passed": { "name": "Filter water passed" } + }, + "button": { + "boil_water": { + "name": "Boil water" + } } }, "config": { diff --git a/homeassistant/components/tankerkoenig/diagnostics.py b/homeassistant/components/tankerkoenig/diagnostics.py new file mode 100644 index 00000000000000..811ec07ef19406 --- /dev/null +++ b/homeassistant/components/tankerkoenig/diagnostics.py @@ -0,0 +1,32 @@ +"""Diagnostics support for Tankerkoenig.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UNIQUE_ID, +) +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TankerkoenigDataUpdateCoordinator + +TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIQUE_ID} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + diag_data = { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": coordinator.data, + } + return diag_data diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 42fc849a2cf10d..2ce81772774f7a 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.7.3"] + "requirements": ["HATasmota==0.8.0"] } diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index 21030b8c14b07c..48dbe51fd6752a 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -112,8 +112,11 @@ class TasmotaAvailability(TasmotaEntity): def __init__(self, **kwds: Any) -> None: """Initialize the availability mixin.""" - self._available = False super().__init__(**kwds) + if self._tasmota_entity.deep_sleep_enabled: + self._available = True + else: + self._available = False async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" @@ -122,6 +125,8 @@ async def async_added_to_hass(self) -> None: async_subscribe_connection_status(self.hass, self.async_mqtt_connected) ) await super().async_added_to_hass() + if self._tasmota_entity.deep_sleep_enabled: + await self._tasmota_entity.poll_status() async def availability_updated(self, available: bool) -> None: """Handle updated availability.""" @@ -135,6 +140,8 @@ def async_mqtt_connected(self, _: bool) -> None: if not self.hass.is_stopping: if not mqtt_connected(self.hass): self._available = False + elif self._tasmota_entity.deep_sleep_enabled: + self._available = True self.async_write_ha_state() @property diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index a64f4312de1b16..ca9de9df8dec75 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -43,14 +43,14 @@ def get_top_stats( return value -@dataclass +@dataclass(frozen=True) class TautulliSensorEntityMixin: """Mixin for Tautulli sensor.""" value_fn: Callable[[PyTautulliApiHomeStats, PyTautulliApiActivity, str], StateType] -@dataclass +@dataclass(frozen=True) class TautulliSensorEntityDescription( SensorEntityDescription, TautulliSensorEntityMixin ): @@ -151,14 +151,14 @@ class TautulliSensorEntityDescription( ) -@dataclass +@dataclass(frozen=True) class TautulliSessionSensorEntityMixin: """Mixin for Tautulli session sensor.""" value_fn: Callable[[PyTautulliApiSession], StateType] -@dataclass +@dataclass(frozen=True) class TautulliSessionSensorEntityDescription( SensorEntityDescription, TautulliSessionSensorEntityMixin ): diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py new file mode 100644 index 00000000000000..eeb0f8e0d5a005 --- /dev/null +++ b/homeassistant/components/tedee/__init__.py @@ -0,0 +1,53 @@ +"""Init the tedee component.""" +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN +from .coordinator import TedeeApiCoordinator + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Integration setup.""" + + coordinator = TedeeApiCoordinator(hass) + + await coordinator.async_config_entry_first_refresh() + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, coordinator.bridge.serial)}, + manufacturer="Tedee", + name=coordinator.bridge.name, + model="Bridge", + serial_number=coordinator.bridge.serial, + ) + + 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.""" + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py new file mode 100644 index 00000000000000..9bb2cd0410c11a --- /dev/null +++ b/homeassistant/components/tedee/binary_sensor.py @@ -0,0 +1,78 @@ +"""Tedee sensor entities.""" +from collections.abc import Callable +from dataclasses import dataclass + +from pytedee_async import TedeeLock +from pytedee_async.lock import TedeeLockState + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import TedeeDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class TedeeBinarySensorEntityDescription( + BinarySensorEntityDescription, +): + """Describes Tedee binary sensor entity.""" + + is_on_fn: Callable[[TedeeLock], bool | None] + + +ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( + TedeeBinarySensorEntityDescription( + key="charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + is_on_fn=lambda lock: lock.is_charging, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TedeeBinarySensorEntityDescription( + key="semi_locked", + translation_key="semi_locked", + is_on_fn=lambda lock: lock.state == TedeeLockState.HALF_OPEN, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TedeeBinarySensorEntityDescription( + key="pullspring_enabled", + translation_key="pullspring_enabled", + is_on_fn=lambda lock: lock.is_enabled_pullspring, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tedee sensor entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + for entity_description in ENTITIES: + async_add_entities( + [ + TedeeBinarySensorEntity(lock, coordinator, entity_description) + for lock in coordinator.data.values() + ] + ) + + +class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity): + """Tedee sensor entity.""" + + entity_description: TedeeBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self._lock) diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py new file mode 100644 index 00000000000000..27f455ee20c909 --- /dev/null +++ b/homeassistant/components/tedee/config_flow.py @@ -0,0 +1,93 @@ +"""Config flow for Tedee integration.""" +from collections.abc import Mapping +from typing import Any + +from pytedee_async import ( + TedeeAuthException, + TedeeClient, + TedeeClientException, + TedeeLocalAuthException, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN, NAME + + +class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tedee.""" + + 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: + if self.reauth_entry: + host = self.reauth_entry.data[CONF_HOST] + else: + host = user_input[CONF_HOST] + local_access_token = user_input[CONF_LOCAL_ACCESS_TOKEN] + tedee_client = TedeeClient( + local_token=local_access_token, + local_ip=host, + session=async_get_clientsession(self.hass), + ) + try: + local_bridge = await tedee_client.get_local_bridge() + except (TedeeAuthException, TedeeLocalAuthException): + errors[CONF_LOCAL_ACCESS_TOKEN] = "invalid_api_key" + except TedeeClientException: + errors[CONF_HOST] = "invalid_host" + else: + if self.reauth_entry: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={**self.reauth_entry.data, **user_input}, + ) + await self.hass.config_entries.async_reload( + self.context["entry_id"] + ) + return self.async_abort(reason="reauth_successful") + await self.async_set_unique_id(local_bridge.serial) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=NAME, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, + ): str, + vol.Required( + CONF_LOCAL_ACCESS_TOKEN, + ): str, + } + ), + errors=errors, + ) + + 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 self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_LOCAL_ACCESS_TOKEN, + default=entry_data[CONF_LOCAL_ACCESS_TOKEN], + ): str, + } + ), + ) diff --git a/homeassistant/components/tedee/const.py b/homeassistant/components/tedee/const.py new file mode 100644 index 00000000000000..bac5bfaec44b6d --- /dev/null +++ b/homeassistant/components/tedee/const.py @@ -0,0 +1,9 @@ +"""Constants for the Tedee integration.""" +from datetime import timedelta + +DOMAIN = "tedee" +NAME = "Tedee" + +SCAN_INTERVAL = timedelta(seconds=10) + +CONF_LOCAL_ACCESS_TOKEN = "local_access_token" diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py new file mode 100644 index 00000000000000..2b4f3c6d26b7de --- /dev/null +++ b/homeassistant/components/tedee/coordinator.py @@ -0,0 +1,100 @@ +"""Coordinator for Tedee locks.""" +from collections.abc import Awaitable, Callable +from datetime import timedelta +import logging +import time + +from pytedee_async import ( + TedeeClient, + TedeeClientException, + TedeeDataUpdateException, + TedeeLocalAuthException, + TedeeLock, +) +from pytedee_async.bridge import TedeeBridge + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +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_LOCAL_ACCESS_TOKEN, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=20) +GET_LOCKS_INTERVAL_SECONDS = 3600 + +_LOGGER = logging.getLogger(__name__) + + +class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): + """Class to handle fetching data from the tedee API centrally.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + self._bridge: TedeeBridge | None = None + self.tedee_client = TedeeClient( + local_token=self.config_entry.data[CONF_LOCAL_ACCESS_TOKEN], + local_ip=self.config_entry.data[CONF_HOST], + session=async_get_clientsession(hass), + ) + + self._next_get_locks = time.time() + + @property + def bridge(self) -> TedeeBridge: + """Return bridge.""" + assert self._bridge + return self._bridge + + async def _async_update_data(self) -> dict[int, TedeeLock]: + """Fetch data from API endpoint.""" + if self._bridge is None: + + async def _async_get_bridge() -> None: + self._bridge = await self.tedee_client.get_local_bridge() + + _LOGGER.debug("Update coordinator: Getting bridge from API") + await self._async_update(_async_get_bridge) + + _LOGGER.debug("Update coordinator: Getting locks from API") + # once every hours get all lock details, otherwise use the sync endpoint + if self._next_get_locks <= time.time(): + _LOGGER.debug("Updating through /my/lock endpoint") + await self._async_update(self.tedee_client.get_locks) + self._next_get_locks = time.time() + GET_LOCKS_INTERVAL_SECONDS + else: + _LOGGER.debug("Updating through /sync endpoint") + await self._async_update(self.tedee_client.sync) + + _LOGGER.debug( + "available_locks: %s", + ", ".join(map(str, self.tedee_client.locks_dict.keys())), + ) + + return self.tedee_client.locks_dict + + async def _async_update(self, update_fn: Callable[[], Awaitable[None]]) -> None: + """Update locks based on update function.""" + try: + await update_fn() + except TedeeLocalAuthException as ex: + raise ConfigEntryAuthFailed( + "Authentication failed. Local access token is invalid" + ) from ex + + except TedeeDataUpdateException as ex: + _LOGGER.debug("Error while updating data: %s", str(ex)) + raise UpdateFailed("Error while updating data: %s" % str(ex)) from ex + except (TedeeClientException, TimeoutError) as ex: + raise UpdateFailed("Querying API failed. Error: %s" % str(ex)) from ex diff --git a/homeassistant/components/tedee/diagnostics.py b/homeassistant/components/tedee/diagnostics.py new file mode 100644 index 00000000000000..d17c4c335bc91e --- /dev/null +++ b/homeassistant/components/tedee/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for tedee.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TedeeApiCoordinator + +TO_REDACT = { + "lock_id", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: TedeeApiCoordinator = hass.data[DOMAIN][entry.entry_id] + # dict has sensitive info as key, redact manually + data = { + index: lock.to_dict() + for index, (_, lock) in enumerate(coordinator.tedee_client.locks_dict.items()) + } + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py new file mode 100644 index 00000000000000..6dfcbebe3de3c8 --- /dev/null +++ b/homeassistant/components/tedee/entity.py @@ -0,0 +1,58 @@ +"""Bases for Tedee entities.""" + +from pytedee_async.lock import TedeeLock + +from homeassistant.core import callback +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 TedeeApiCoordinator + + +class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): + """Base class for Tedee entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + lock: TedeeLock, + coordinator: TedeeApiCoordinator, + key: str, + ) -> None: + """Initialize Tedee entity.""" + super().__init__(coordinator) + self._lock = lock + self._attr_unique_id = f"{lock.lock_id}-{key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(lock.lock_id))}, + name=lock.lock_name, + manufacturer="Tedee", + model=lock.lock_type, + via_device=(DOMAIN, coordinator.bridge.serial), + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._lock = self.coordinator.data[self._lock.lock_id] + super()._handle_coordinator_update() + + +class TedeeDescriptionEntity(TedeeEntity): + """Base class for Tedee device entities.""" + + entity_description: EntityDescription + + def __init__( + self, + lock: TedeeLock, + coordinator: TedeeApiCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize Tedee device entity.""" + super().__init__(lock, coordinator, entity_description.key) + self.entity_description = entity_description diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py new file mode 100644 index 00000000000000..751dfb446b72ba --- /dev/null +++ b/homeassistant/components/tedee/lock.py @@ -0,0 +1,119 @@ +"""Tedee lock entities.""" +from typing import Any + +from pytedee_async import TedeeClientException, TedeeLock, TedeeLockState + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TedeeApiCoordinator +from .entity import TedeeEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tedee lock entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[TedeeLockEntity] = [] + for lock in coordinator.data.values(): + if lock.is_enabled_pullspring: + entities.append(TedeeLockWithLatchEntity(lock, coordinator)) + else: + entities.append(TedeeLockEntity(lock, coordinator)) + + async_add_entities(entities) + + +class TedeeLockEntity(TedeeEntity, LockEntity): + """A tedee lock that doesn't have pullspring enabled.""" + + _attr_name = None + + def __init__( + self, + lock: TedeeLock, + coordinator: TedeeApiCoordinator, + ) -> None: + """Initialize the lock.""" + super().__init__(lock, coordinator, "lock") + + @property + def is_locked(self) -> bool: + """Return true if lock is locked.""" + return self._lock.state == TedeeLockState.LOCKED + + @property + def is_unlocking(self) -> bool: + """Return true if lock is unlocking.""" + return self._lock.state == TedeeLockState.UNLOCKING + + @property + def is_locking(self) -> bool: + """Return true if lock is locking.""" + return self._lock.state == TedeeLockState.LOCKING + + @property + def is_jammed(self) -> bool: + """Return true if lock is jammed.""" + return self._lock.is_state_jammed + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._lock.is_connected + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the door.""" + try: + self._lock.state = TedeeLockState.UNLOCKING + self.async_write_ha_state() + + await self.coordinator.tedee_client.unlock(self._lock.lock_id) + await self.coordinator.async_request_refresh() + except (TedeeClientException, Exception) as ex: + raise HomeAssistantError( + "Failed to unlock the door. Lock %s" % self._lock.lock_id + ) from ex + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the door.""" + try: + self._lock.state = TedeeLockState.LOCKING + self.async_write_ha_state() + + await self.coordinator.tedee_client.lock(self._lock.lock_id) + await self.coordinator.async_request_refresh() + except (TedeeClientException, Exception) as ex: + raise HomeAssistantError( + "Failed to lock the door. Lock %s" % self._lock.lock_id + ) from ex + + +class TedeeLockWithLatchEntity(TedeeLockEntity): + """A tedee lock but has pullspring enabled, so it additional features.""" + + @property + def supported_features(self) -> LockEntityFeature: + """Flag supported features.""" + return LockEntityFeature.OPEN + + async def async_open(self, **kwargs: Any) -> None: + """Open the door with pullspring.""" + try: + self._lock.state = TedeeLockState.UNLOCKING + self.async_write_ha_state() + + await self.coordinator.tedee_client.open(self._lock.lock_id) + await self.coordinator.async_request_refresh() + except (TedeeClientException, Exception) as ex: + raise HomeAssistantError( + "Failed to unlatch the door. Lock %s" % self._lock.lock_id + ) from ex diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json new file mode 100644 index 00000000000000..f170d116ff7ac3 --- /dev/null +++ b/homeassistant/components/tedee/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tedee", + "name": "Tedee", + "codeowners": ["@patrickhilker", "@zweckj"], + "config_flow": true, + "dependencies": ["http"], + "documentation": "https://www.home-assistant.io/integrations/tedee", + "iot_class": "local_push", + "requirements": ["pytedee-async==0.2.6"] +} diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py new file mode 100644 index 00000000000000..9eb61e624c783e --- /dev/null +++ b/homeassistant/components/tedee/sensor.py @@ -0,0 +1,74 @@ +"""Tedee sensor entities.""" +from collections.abc import Callable +from dataclasses import dataclass + +from pytedee_async import TedeeLock + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import TedeeDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class TedeeSensorEntityDescription(SensorEntityDescription): + """Describes Tedee sensor entity.""" + + value_fn: Callable[[TedeeLock], float | None] + + +ENTITIES: tuple[TedeeSensorEntityDescription, ...] = ( + TedeeSensorEntityDescription( + key="battery_sensor", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda lock: lock.battery_level, + ), + TedeeSensorEntityDescription( + key="pullspring_duration", + translation_key="pullspring_duration", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.TOTAL, + icon="mdi:timer-lock-open", + value_fn=lambda lock: lock.duration_pullspring, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tedee sensor entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + for entity_description in ENTITIES: + async_add_entities( + [ + TedeeSensorEntity(lock, coordinator, entity_description) + for lock in coordinator.data.values() + ] + ) + + +class TedeeSensorEntity(TedeeDescriptionEntity, SensorEntity): + """Tedee sensor entity.""" + + entity_description: TedeeSensorEntityDescription + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._lock) diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json new file mode 100644 index 00000000000000..1f0a5f0dc7e53d --- /dev/null +++ b/homeassistant/components/tedee/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup your tedee locks", + "data": { + "local_access_token": "Local access token", + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The IP address of the bridge you want to connect to.", + "local_access_token": "You can find it in the tedee app under \"Bridge Settings\" -> \"Local API\"." + } + }, + "reauth_confirm": { + "title": "Update of access key required", + "description": "Tedee needs an updated access key, because the existing one is invalid, or might have expired.", + "data": { + "local_access_token": "[%key:component::tedee::config::step::user::data::local_access_token%]" + }, + "data_description": { + "local_access_token": "[%key:component::tedee::config::step::user::data_description::local_access_token%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "binary_sensor": { + "pullspring_enabled": { + "name": "Pullspring enabled" + }, + "semi_locked": { + "name": "Semi locked" + } + }, + "sensor": { + "pullspring_duration": { + "name": "Pullspring duration" + } + } + } +} diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 76677c3813ef17..1d71e055e2eba4 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -55,6 +55,7 @@ ATTR_CAPTION = "caption" ATTR_CHAT_ID = "chat_id" ATTR_CHAT_INSTANCE = "chat_instance" +ATTR_DATE = "date" ATTR_DISABLE_NOTIF = "disable_notification" ATTR_DISABLE_WEB_PREV = "disable_web_page_preview" ATTR_EDITED_MSG = "edited_message" @@ -786,6 +787,7 @@ def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs): 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 +801,7 @@ def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs): 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 +815,7 @@ def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs): 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 +829,7 @@ def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs): 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 +843,7 @@ def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs): 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 +856,7 @@ def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs): 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 +879,7 @@ def send_sticker(self, target=None, **kwargs): 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 +903,7 @@ def send_location(self, latitude, longitude, target=None, **kwargs): 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 +932,7 @@ def send_poll( 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], ) @@ -982,6 +992,7 @@ def _get_message_event_data(self, message: Message) -> tuple[str, dict[str, Any] event_data: dict[str, Any] = { ATTR_MSGID: message.message_id, ATTR_CHAT_ID: message.chat.id, + ATTR_DATE: message.date, } if Filters.command.filter(message): # This is a command message - set event type to command and split data into command and args diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 94d1eee1b5545c..1587f75450867d 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 4dfe0a28d01cdf..de5de685409812 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 1dbea7a0e6cacb..16c847f0077117 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/temper/manifest.json b/homeassistant/components/temper/manifest.json index 527a4b95b718d3..dbad8827877925 100644 --- a/homeassistant/components/temper/manifest.json +++ b/homeassistant/components/temper/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/temper", "iot_class": "local_polling", "loggers": ["pyusb", "temperusb"], - "requirements": ["temperusb==1.6.0"] + "requirements": ["temperusb==1.6.1"] } diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 22919ac9e708e1..d52dc0cf166d1f 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -34,8 +34,9 @@ async def _reload_config(call: Event | ServiceCall) -> None: _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 3329f185f08478..9da43082d2bd7d 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -10,10 +10,13 @@ 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 @@ ) -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 d39fa56775a625..8aeede4255226f 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -282,15 +282,6 @@ async def async_set_percentage(self, percentage: int) -> None: 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/image.py b/homeassistant/components/template/image.py index 55a0e2fb72dbd4..227109d59e20fb 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -94,9 +94,9 @@ def __init__( @property def entity_picture(self) -> str | None: """Return entity picture.""" - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 if self._entity_picture_template: - return TemplateEntity.entity_picture.fget(self) # type: ignore[attr-defined] + return TemplateEntity.entity_picture.__get__(self) + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 return ImageEntity.entity_picture.fget(self) # type: ignore[attr-defined] @callback diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index b3f276240b5910..89c4826f1e6ec5 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -11,6 +11,9 @@ 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 @@ _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 @@ 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 @@ def __init__( 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 @@ def __init__( 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 @@ def min_mireds(self) -> int: @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 @@ def effect_list(self) -> list[str] | None: @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 @@ def _async_setup_templates(self) -> None: ) 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 @@ def _async_setup_templates(self) -> None: ) 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 @@ async def async_turn_on(self, **kwargs: Any) -> None: "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 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 @@ def _update_temperature(self, render): " 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 @@ def _update_color(self, render): 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 @@ def _update_color(self, render): 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/template_entity.py b/homeassistant/components/template/template_entity.py index 8c3554c067e601..f9c61850e58d70 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -430,14 +430,17 @@ def _handle_results( return try: - state, attrs = self._async_generate_attributes() - validate_state(state) + calculated_state = self._async_calculate_state() + validate_state(calculated_state.state) except Exception as err: # pylint: disable=broad-exception-caught self._preview_callback(None, None, None, str(err)) else: assert self._template_result_info self._preview_callback( - state, attrs, self._template_result_info.listeners, None + calculated_state.state, + calculated_state.attributes, + self._template_result_info.listeners, + None, ) @callback diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 4e9149ebd07ef6..0a00d1e79b4782 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -57,7 +57,8 @@ 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/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 41403ab84f2254..30e61dc7744219 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -152,7 +152,7 @@ def device_info(self) -> DeviceInfo: ) -@dataclass() +@dataclass(frozen=True) class WallConnectorLambdaValueGetterMixin: """Mixin with a function pointer for getting sensor value.""" diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index e0a34460c8ce44..e9ac03c69e1f9d 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class WallConnectorBinarySensorDescription( BinarySensorEntityDescription, WallConnectorLambdaValueGetterMixin ): diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 0322830890a8cd..1b9433eb696516 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class WallConnectorSensorDescription( SensorEntityDescription, WallConnectorLambdaValueGetterMixin ): diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 982894eb17c95a..97bac988d162e9 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/tessie/__init__.py b/homeassistant/components/tessie/__init__.py new file mode 100644 index 00000000000000..869cd46cf519fd --- /dev/null +++ b/homeassistant/components/tessie/__init__.py @@ -0,0 +1,78 @@ +"""Tessie integration.""" +from http import HTTPStatus +import logging + +from aiohttp import ClientError, ClientResponseError +from tessie_api import get_state_of_all_vehicles + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import TessieStateUpdateCoordinator +from .models import TessieVehicle + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.DEVICE_TRACKER, + Platform.LOCK, + Platform.MEDIA_PLAYER, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tessie config.""" + api_key = entry.data[CONF_ACCESS_TOKEN] + + try: + vehicles = await get_state_of_all_vehicles( + session=async_get_clientsession(hass), + api_key=api_key, + only_active=True, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed from e + _LOGGER.error("Setup failed, unable to connect to Tessie: %s", e) + return False + except ClientError as e: + raise ConfigEntryNotReady from e + + data = [ + TessieVehicle( + state_coordinator=TessieStateUpdateCoordinator( + hass, + api_key=api_key, + vin=vehicle["vin"], + data=vehicle["last_state"], + ) + ) + for vehicle in vehicles["results"] + if vehicle["last_state"] is not None + ] + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Tessie Config.""" + 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/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py new file mode 100644 index 00000000000000..5edbb1085688d4 --- /dev/null +++ b/homeassistant/components/tessie/binary_sensor.py @@ -0,0 +1,167 @@ +"""Binary Sensor platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieStatus +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Tessie binary sensor entity.""" + + is_on: Callable[..., bool] = lambda x: x + + +DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( + TessieBinarySensorEntityDescription( + key="state", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on=lambda x: x == TessieStatus.ONLINE, + ), + TessieBinarySensorEntityDescription( + key="charge_state_battery_heater_on", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="charge_state_charging_state", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + is_on=lambda x: x == "Charging", + ), + TessieBinarySensorEntityDescription( + key="charge_state_preconditioning_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="charge_state_scheduled_charging_pending", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="charge_state_trip_charging", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_auto_seat_climate_left", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_auto_seat_climate_right", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_auto_steering_wheel_heat", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_cabin_overheat_protection", + device_class=BinarySensorDeviceClass.RUNNING, + is_on=lambda x: x == "On", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_cabin_overheat_protection_actively_cooling", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_dashcam_state", + device_class=BinarySensorDeviceClass.RUNNING, + is_on=lambda x: x == "Recording", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_is_user_present", + device_class=BinarySensorDeviceClass.PRESENCE, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_fl", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_fr", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_rl", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_rr", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_fd_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_fp_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_rd_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_rp_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie binary sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieBinarySensorEntity(vehicle.state_coordinator, description) + for vehicle in data + for description in DESCRIPTIONS + if description.key in vehicle.state_coordinator.data + ) + + +class TessieBinarySensorEntity(TessieEntity, BinarySensorEntity): + """Base class for Tessie binary sensors.""" + + entity_description: TessieBinarySensorEntityDescription + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + description: TessieBinarySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.is_on(self._value) diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py new file mode 100644 index 00000000000000..86065d389a4eaf --- /dev/null +++ b/homeassistant/components/tessie/button.py @@ -0,0 +1,82 @@ +"""Button platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from tessie_api import ( + boombox, + enable_keyless_driving, + flash_lights, + honk, + trigger_homelink, + wake, +) + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +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 TessieStateUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieButtonEntityDescription(ButtonEntityDescription): + """Describes a Tessie Button entity.""" + + func: Callable + + +DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( + TessieButtonEntityDescription(key="wake", func=lambda: wake, icon="mdi:sleep-off"), + TessieButtonEntityDescription( + key="flash_lights", func=lambda: flash_lights, icon="mdi:flashlight" + ), + TessieButtonEntityDescription(key="honk", func=lambda: honk, icon="mdi:bullhorn"), + TessieButtonEntityDescription( + key="trigger_homelink", func=lambda: trigger_homelink, icon="mdi:garage" + ), + TessieButtonEntityDescription( + key="enable_keyless_driving", + func=lambda: enable_keyless_driving, + icon="mdi:car-key", + ), + TessieButtonEntityDescription( + key="boombox", func=lambda: boombox, icon="mdi:volume-high" + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Button platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieButtonEntity(vehicle.state_coordinator, description) + for vehicle in data + for description in DESCRIPTIONS + ) + + +class TessieButtonEntity(TessieEntity, ButtonEntity): + """Base class for Tessie Buttons.""" + + entity_description: TessieButtonEntityDescription + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + description: TessieButtonEntityDescription, + ) -> None: + """Initialize the Button.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + async def async_press(self) -> None: + """Press the button.""" + await self.run(self.entity_description.func()) diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py new file mode 100644 index 00000000000000..8d27305cb0b6c6 --- /dev/null +++ b/homeassistant/components/tessie/climate.py @@ -0,0 +1,136 @@ +"""Climate platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import ( + set_climate_keeper_mode, + set_temperature, + start_climate_preconditioning, + stop_climate, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieClimateKeeper +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Climate platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieClimateEntity(vehicle.state_coordinator) for vehicle in data + ) + + +class TessieClimateEntity(TessieEntity, ClimateEntity): + """Vehicle Location Climate Class.""" + + _attr_precision = PRECISION_HALVES + _attr_min_temp = 15 + _attr_max_temp = 28 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_preset_modes: list = [ + TessieClimateKeeper.OFF, + TessieClimateKeeper.ON, + TessieClimateKeeper.DOG, + TessieClimateKeeper.CAMP, + ] + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the Climate entity.""" + super().__init__(coordinator, "primary") + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac operation ie. heat, cool mode.""" + if self.get("climate_state_is_climate_on"): + return HVACMode.HEAT_COOL + return HVACMode.OFF + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.get("climate_state_inside_temp") + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.get("climate_state_driver_temp_setting") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.get("climate_state_max_avail_temp", self._attr_max_temp) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.get("climate_state_min_avail_temp", self._attr_min_temp) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self.get("climate_state_climate_keeper_mode") + + async def async_turn_on(self) -> None: + """Set the climate state to on.""" + await self.run(start_climate_preconditioning) + self.set(("climate_state_is_climate_on", True)) + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + await self.run(stop_climate) + self.set( + ("climate_state_is_climate_on", False), + ("climate_state_climate_keeper_mode", "off"), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the climate temperature.""" + temp = kwargs[ATTR_TEMPERATURE] + await self.run(set_temperature, temperature=temp) + self.set(("climate_state_driver_temp_setting", temp)) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + else: + await self.async_turn_on() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the climate preset mode.""" + await self.run( + set_climate_keeper_mode, mode=self._attr_preset_modes.index(preset_mode) + ) + self.set( + ( + "climate_state_climate_keeper_mode", + preset_mode, + ), + ( + "climate_state_is_climate_on", + preset_mode != self._attr_preset_modes[0], + ), + ) diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py new file mode 100644 index 00000000000000..97d9d44af70125 --- /dev/null +++ b/homeassistant/components/tessie/config_flow.py @@ -0,0 +1,107 @@ +"""Config Flow for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any + +from aiohttp import ClientConnectionError, ClientResponseError +from tessie_api import get_state_of_all_vehicles +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +TESSIE_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +DESCRIPTION_PLACEHOLDERS = { + "url": "[my.tessie.com/settings/api](https://my.tessie.com/settings/api)" +} + + +class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config Tessie API connection.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get configuration from the user.""" + errors: dict[str, str] = {} + if user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + only_active=True, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="Tessie", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=TESSIE_SCHEMA, + description_placeholders=DESCRIPTION_PLACEHOLDERS, + errors=errors, + ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle re-auth.""" + 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: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get update API Key from the user.""" + errors: dict[str, str] = {} + assert self._reauth_entry + if user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + 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") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=TESSIE_SCHEMA, + description_placeholders=DESCRIPTION_PLACEHOLDERS, + errors=errors, + ) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py new file mode 100644 index 00000000000000..2ba4e5145793b6 --- /dev/null +++ b/homeassistant/components/tessie/const.py @@ -0,0 +1,55 @@ +"""Constants used by Tessie integration.""" +from __future__ import annotations + +from enum import IntEnum, StrEnum + +DOMAIN = "tessie" + +MODELS = { + "model3": "Model 3", + "modelx": "Model X", + "modely": "Model Y", + "models": "Model S", +} + + +class TessieStatus(StrEnum): + """Tessie status.""" + + ASLEEP = "asleep" + ONLINE = "online" + + +class TessieSeatHeaterOptions(StrEnum): + """Tessie seat heater options.""" + + OFF = "off" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class TessieClimateKeeper(StrEnum): + """Tessie Climate Keeper Modes.""" + + OFF = "off" + ON = "on" + DOG = "dog" + CAMP = "camp" + + +class TessieUpdateStatus(StrEnum): + """Tessie Update Statuses.""" + + AVAILABLE = "available" + DOWNLOADING = "downloading" + INSTALLING = "installing" + WIFI_WAIT = "downloading_wifi_wait" + SCHEDULED = "scheduled" + + +class TessieCoverStates(IntEnum): + """Tessie Cover states.""" + + CLOSED = 0 + OPEN = 1 diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py new file mode 100644 index 00000000000000..c2f53da53bc30d --- /dev/null +++ b/homeassistant/components/tessie/coordinator.py @@ -0,0 +1,82 @@ +"""Tessie Data Coordinator.""" +from datetime import timedelta +from http import HTTPStatus +import logging +from typing import Any + +from aiohttp import ClientResponseError +from tessie_api import get_state + +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 + +from .const import TessieStatus + +# This matches the update interval Tessie performs server side +TESSIE_SYNC_INTERVAL = 10 + +_LOGGER = logging.getLogger(__name__) + + +class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Tessie API.""" + + def __init__( + self, + hass: HomeAssistant, + api_key: str, + vin: str, + data: dict[str, Any], + ) -> None: + """Initialize Tessie Data Update Coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Tessie", + update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), + ) + self.api_key = api_key + self.vin = vin + self.session = async_get_clientsession(hass) + self.data = self._flatten(data) + self.did_first_update = False + + async def _async_update_data(self) -> dict[str, Any]: + """Update vehicle data using Tessie API.""" + try: + vehicle = await get_state( + session=self.session, + api_key=self.api_key, + vin=self.vin, + use_cache=self.did_first_update, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + # Auth Token is no longer valid + raise ConfigEntryAuthFailed from e + raise e + + self.did_first_update = True + if vehicle["state"] == TessieStatus.ONLINE: + # Vehicle is online, all data is fresh + return self._flatten(vehicle) + + # Vehicle is asleep, only update state + self.data["state"] = vehicle["state"] + return self.data + + def _flatten( + self, data: dict[str, Any], parent: str | None = None + ) -> dict[str, Any]: + """Flatten the data structure.""" + result = {} + for key, value in data.items(): + if parent: + key = f"{parent}_{key}" + if isinstance(value, dict): + result.update(self._flatten(value, key)) + else: + result[key] = value + return result diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py new file mode 100644 index 00000000000000..6b4393fce1fc29 --- /dev/null +++ b/homeassistant/components/tessie/cover.py @@ -0,0 +1,160 @@ +"""Cover platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import ( + close_charge_port, + close_windows, + open_close_rear_trunk, + open_front_trunk, + open_unlock_charge_port, + vent_windows, +) + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieCoverStates +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + klass(vehicle.state_coordinator) + for klass in ( + TessieWindowEntity, + TessieChargePortEntity, + TessieFrontTrunkEntity, + TessieRearTrunkEntity, + ) + for vehicle in data + ) + + +class TessieWindowEntity(TessieEntity, CoverEntity): + """Cover entity for current charge.""" + + _attr_device_class = CoverDeviceClass.WINDOW + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "windows") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return ( + self.get("vehicle_state_fd_window") == TessieCoverStates.CLOSED + and self.get("vehicle_state_fp_window") == TessieCoverStates.CLOSED + and self.get("vehicle_state_rd_window") == TessieCoverStates.CLOSED + and self.get("vehicle_state_rp_window") == TessieCoverStates.CLOSED + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open windows.""" + await self.run(vent_windows) + self.set( + ("vehicle_state_fd_window", TessieCoverStates.OPEN), + ("vehicle_state_fp_window", TessieCoverStates.OPEN), + ("vehicle_state_rd_window", TessieCoverStates.OPEN), + ("vehicle_state_rp_window", TessieCoverStates.OPEN), + ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + await self.run(close_windows) + self.set( + ("vehicle_state_fd_window", TessieCoverStates.CLOSED), + ("vehicle_state_fp_window", TessieCoverStates.CLOSED), + ("vehicle_state_rd_window", TessieCoverStates.CLOSED), + ("vehicle_state_rp_window", TessieCoverStates.CLOSED), + ) + + +class TessieChargePortEntity(TessieEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "charge_state_charge_port_door_open") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return not self._value + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open windows.""" + await self.run(open_unlock_charge_port) + self.set((self.key, True)) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + await self.run(close_charge_port) + self.set((self.key, False)) + + +class TessieFrontTrunkEntity(TessieEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN + + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_ft") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return self._value == TessieCoverStates.CLOSED + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open front trunk.""" + await self.run(open_front_trunk) + self.set((self.key, TessieCoverStates.OPEN)) + + +class TessieRearTrunkEntity(TessieEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_rt") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return self._value == TessieCoverStates.CLOSED + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open rear trunk.""" + if self._value == TessieCoverStates.CLOSED: + await self.run(open_close_rear_trunk) + self.set((self.key, TessieCoverStates.OPEN)) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close rear trunk.""" + if self._value == TessieCoverStates.OPEN: + await self.run(open_close_rear_trunk) + self.set((self.key, TessieCoverStates.CLOSED)) diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py new file mode 100644 index 00000000000000..9b1ddfcfe4faff --- /dev/null +++ b/homeassistant/components/tessie/device_tracker.py @@ -0,0 +1,85 @@ +"""Device Tracker platform for Tessie integration.""" +from __future__ import annotations + +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.config_entries import ConfigEntry +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 TessieStateUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie device tracker platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + klass(vehicle.state_coordinator) + for klass in ( + TessieDeviceTrackerLocationEntity, + TessieDeviceTrackerRouteEntity, + ) + for vehicle in data + ) + + +class TessieDeviceTrackerEntity(TessieEntity, TrackerEntity): + """Base class for Tessie Tracker Entities.""" + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the device tracker.""" + super().__init__(coordinator, self.key) + + @property + def source_type(self) -> SourceType | str: + """Return the source type of the device tracker.""" + return SourceType.GPS + + +class TessieDeviceTrackerLocationEntity(TessieDeviceTrackerEntity): + """Vehicle Location Device Tracker Class.""" + + key = "location" + + @property + def longitude(self) -> float | None: + """Return the longitude of the device tracker.""" + return self.get("drive_state_longitude") + + @property + def latitude(self) -> float | None: + """Return the latitude of the device tracker.""" + return self.get("drive_state_latitude") + + @property + def extra_state_attributes(self) -> dict[str, StateType] | None: + """Return device state attributes.""" + return { + "heading": self.get("drive_state_heading"), + "speed": self.get("drive_state_speed"), + } + + +class TessieDeviceTrackerRouteEntity(TessieDeviceTrackerEntity): + """Vehicle Navigation Device Tracker Class.""" + + key = "route" + + @property + def longitude(self) -> float | None: + """Return the longitude of the device tracker.""" + return self.get("drive_state_active_route_longitude") + + @property + def latitude(self) -> float | None: + """Return the latitude of the device tracker.""" + return self.get("drive_state_active_route_latitude") diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py new file mode 100644 index 00000000000000..bfedd7eb43d6d9 --- /dev/null +++ b/homeassistant/components/tessie/entity.py @@ -0,0 +1,77 @@ +"""Tessie parent entity class.""" + +from collections.abc import Awaitable, Callable +from typing import Any + +from aiohttp import ClientResponseError + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MODELS +from .coordinator import TessieStateUpdateCoordinator + + +class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): + """Parent class for Tessie Entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + key: str, + ) -> None: + """Initialize common aspects of a Tessie entity.""" + super().__init__(coordinator) + self.vin = coordinator.vin + self.key = key + + car_type = coordinator.data["vehicle_config_car_type"] + + self._attr_translation_key = key + self._attr_unique_id = f"{self.vin}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.vin)}, + manufacturer="Tesla", + configuration_url="https://my.tessie.com/", + name=coordinator.data["display_name"], + model=MODELS.get(car_type, car_type), + sw_version=coordinator.data["vehicle_state_car_version"].split(" ")[0], + hw_version=coordinator.data["vehicle_config_driver_assist"], + serial_number=self.vin, + ) + + @property + def _value(self) -> Any: + """Return value from coordinator data.""" + return self.coordinator.data[self.key] + + def get(self, key: str | None = None, default: Any | None = None) -> Any: + """Return a specific value from coordinator data.""" + return self.coordinator.data.get(key or self.key, default) + + async def run( + self, func: Callable[..., Awaitable[dict[str, bool | str]]], **kargs: Any + ) -> None: + """Run a tessie_api function and handle exceptions.""" + try: + response = await func( + session=self.coordinator.session, + vin=self.vin, + api_key=self.coordinator.api_key, + **kargs, + ) + except ClientResponseError as e: + raise HomeAssistantError from e + if response["result"] is False: + raise HomeAssistantError( + response.get("reason", "An unknown issue occurred") + ) + + def set(self, *args: Any) -> None: + """Set a value in coordinator data.""" + for key, value in args: + self.coordinator.data[key] = value + self.async_write_ha_state() diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py new file mode 100644 index 00000000000000..e8fb8930bbcf49 --- /dev/null +++ b/homeassistant/components/tessie/lock.py @@ -0,0 +1,50 @@ +"""Lock platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import lock, unlock + +from homeassistant.components.lock import LockEntity +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 TessieStateUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities(TessieLockEntity(vehicle.state_coordinator) for vehicle in data) + + +class TessieLockEntity(TessieEntity, LockEntity): + """Lock entity for current charge.""" + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_locked") + + @property + def is_locked(self) -> bool | None: + """Return the state of the Lock.""" + return self._value + + async def async_lock(self, **kwargs: Any) -> None: + """Set new value.""" + await self.run(lock) + self.set((self.key, True)) + + async def async_unlock(self, **kwargs: Any) -> None: + """Set new value.""" + await self.run(unlock) + self.set((self.key, False)) diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json new file mode 100644 index 00000000000000..52fc8dd5be11d5 --- /dev/null +++ b/homeassistant/components/tessie/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tessie", + "name": "Tessie", + "codeowners": ["@Bre77"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tessie", + "iot_class": "cloud_polling", + "loggers": ["tessie"], + "requirements": ["tessie-api==0.0.9"] +} diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py new file mode 100644 index 00000000000000..c4392e1de1d803 --- /dev/null +++ b/homeassistant/components/tessie/media_player.py @@ -0,0 +1,108 @@ +"""Media Player platform for Tessie integration.""" +from __future__ import annotations + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerState, +) +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 TessieStateUpdateCoordinator +from .entity import TessieEntity + +STATES = { + "Playing": MediaPlayerState.PLAYING, + "Paused": MediaPlayerState.PAUSED, + "Stopped": MediaPlayerState.IDLE, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Media platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities(TessieMediaEntity(vehicle.state_coordinator) for vehicle in data) + + +class TessieMediaEntity(TessieEntity, MediaPlayerEntity): + """Vehicle Location Media Class.""" + + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the media player entity.""" + super().__init__(coordinator, "media") + + @property + def state(self) -> MediaPlayerState: + """State of the player.""" + return STATES.get( + self.get("vehicle_state_media_info_media_playback_status"), + MediaPlayerState.OFF, + ) + + @property + def volume_level(self) -> float: + """Volume level of the media player (0..1).""" + return self.get("vehicle_state_media_info_audio_volume", 0) / self.get( + "vehicle_state_media_info_audio_volume_max", 10.333333 + ) + + @property + def media_duration(self) -> int | None: + """Duration of current playing media in seconds.""" + if duration := self.get("vehicle_state_media_info_now_playing_duration"): + return duration / 1000 + return None + + @property + def media_position(self) -> int | None: + """Position of current playing media in seconds.""" + # Return media position only when a media duration is > 0 + if self.get("vehicle_state_media_info_now_playing_duration"): + return self.get("vehicle_state_media_info_now_playing_elapsed") / 1000 + return None + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + if title := self.get("vehicle_state_media_info_now_playing_title"): + return title + return None + + @property + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + if artist := self.get("vehicle_state_media_info_now_playing_artist"): + return artist + return None + + @property + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + if album := self.get("vehicle_state_media_info_now_playing_album"): + return album + return None + + @property + def media_playlist(self) -> str | None: + """Title of Playlist currently playing.""" + if playlist := self.get("vehicle_state_media_info_now_playing_station"): + return playlist + return None + + @property + def source(self) -> str | None: + """Name of the current input source.""" + if source := self.get("vehicle_state_media_info_now_playing_source"): + return source + return None diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py new file mode 100644 index 00000000000000..32466a6b2ac9a9 --- /dev/null +++ b/homeassistant/components/tessie/models.py @@ -0,0 +1,13 @@ +"""The Tessie integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .coordinator import TessieStateUpdateCoordinator + + +@dataclass +class TessieVehicle: + """Data for the Tessie integration.""" + + state_coordinator: TessieStateUpdateCoordinator diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py new file mode 100644 index 00000000000000..ada088f1bd2e14 --- /dev/null +++ b/homeassistant/components/tessie/number.py @@ -0,0 +1,137 @@ +"""Number platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from tessie_api import set_charge_limit, set_charging_amps, set_speed_limit + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + PRECISION_WHOLE, + UnitOfElectricCurrent, + UnitOfSpeed, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieNumberEntityDescription(NumberEntityDescription): + """Describes Tessie Number entity.""" + + func: Callable + arg: str + native_min_value: float + native_max_value: float + min_key: str | None = None + max_key: str + + +DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( + TessieNumberEntityDescription( + key="charge_state_charge_current_request", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=32, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=NumberDeviceClass.CURRENT, + max_key="charge_state_charge_current_request_max", + func=lambda: set_charging_amps, + arg="amps", + ), + TessieNumberEntityDescription( + key="charge_state_charge_limit_soc", + native_step=PRECISION_WHOLE, + native_min_value=50, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + min_key="charge_state_charge_limit_soc_min", + max_key="charge_state_charge_limit_soc_max", + func=lambda: set_charge_limit, + arg="percent", + ), + TessieNumberEntityDescription( + key="vehicle_state_speed_limit_mode_current_limit_mph", + native_step=PRECISION_WHOLE, + native_min_value=50, + native_max_value=120, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=NumberDeviceClass.SPEED, + mode=NumberMode.BOX, + min_key="vehicle_state_speed_limit_mode_min_limit_mph", + max_key="vehicle_state_speed_limit_mode_max_limit_mph", + func=lambda: set_speed_limit, + arg="mph", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieNumberEntity(vehicle.state_coordinator, description) + for vehicle in data + for description in DESCRIPTIONS + if description.key in vehicle.state_coordinator.data + ) + + +class TessieNumberEntity(TessieEntity, NumberEntity): + """Number entity for current charge.""" + + entity_description: TessieNumberEntityDescription + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + description: TessieNumberEntityDescription, + ) -> None: + """Initialize the Number entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> float | None: + """Return the value reported by the number.""" + return self._value + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + if self.entity_description.min_key: + return self.get( + self.entity_description.min_key, + self.entity_description.native_min_value, + ) + return self.entity_description.native_min_value + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return self.get( + self.entity_description.max_key, self.entity_description.native_max_value + ) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.run( + self.entity_description.func(), **{self.entity_description.arg: value} + ) + self.set((self.key, value)) diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py new file mode 100644 index 00000000000000..03436b44cfc2de --- /dev/null +++ b/homeassistant/components/tessie/select.py @@ -0,0 +1,58 @@ +"""Select platform for Tessie integration.""" +from __future__ import annotations + +from tessie_api import set_seat_heat + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieSeatHeaterOptions +from .entity import TessieEntity + +SEAT_HEATERS = { + "climate_state_seat_heater_left": "front_left", + "climate_state_seat_heater_right": "front_right", + "climate_state_seat_heater_rear_left": "rear_left", + "climate_state_seat_heater_rear_center": "rear_center", + "climate_state_seat_heater_rear_right": "rear_right", + "climate_state_seat_heater_third_row_left": "third_row_left", + "climate_state_seat_heater_third_row_right": "third_row_right", +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie select platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieSeatHeaterSelectEntity(vehicle.state_coordinator, key) + for vehicle in data + for key in SEAT_HEATERS + if key in vehicle.state_coordinator.data + ) + + +class TessieSeatHeaterSelectEntity(TessieEntity, SelectEntity): + """Select entity for current charge.""" + + _attr_options = [ + TessieSeatHeaterOptions.OFF, + TessieSeatHeaterOptions.LOW, + TessieSeatHeaterOptions.MEDIUM, + TessieSeatHeaterOptions.HIGH, + ] + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self._attr_options[self._value] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + level = self._attr_options.index(option) + await self.run(set_seat_heat, seat=SEAT_HEATERS[self.key], level=level) + self.set((self.key, level)) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py new file mode 100644 index 00000000000000..aaf37e51d61397 --- /dev/null +++ b/homeassistant/components/tessie/sensor.py @@ -0,0 +1,218 @@ +"""Sensor platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +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 TessieStateUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieSensorEntityDescription(SensorEntityDescription): + """Describes Tessie Sensor entity.""" + + value_fn: Callable[[StateType], StateType] = lambda x: x + + +DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( + TessieSensorEntityDescription( + key="charge_state_usable_battery_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + TessieSensorEntityDescription( + key="charge_state_charge_energy_added", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="charge_state_charger_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + TessieSensorEntityDescription( + key="charge_state_charger_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state_charger_actual_current", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state_charge_rate", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state_battery_range", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="drive_state_speed", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + ), + TessieSensorEntityDescription( + key="drive_state_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="drive_state_shift_state", + icon="mdi:car-shift-pattern", + options=["p", "d", "r", "n"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda x: x.lower() if isinstance(x, str) else x, + ), + TessieSensorEntityDescription( + key="vehicle_state_odometer", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state_tpms_pressure_fl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state_tpms_pressure_fr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state_tpms_pressure_rl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state_tpms_pressure_rr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="climate_state_inside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="climate_state_outside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="climate_state_driver_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="climate_state_passenger_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieSensorEntity(vehicle.state_coordinator, description) + for vehicle in data + for description in DESCRIPTIONS + if description.key in vehicle.state_coordinator.data + ) + + +class TessieSensorEntity(TessieEntity, SensorEntity): + """Base class for Tessie metric sensors.""" + + entity_description: TessieSensorEntityDescription + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + description: TessieSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._value) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json new file mode 100644 index 00000000000000..7cf511c125c810 --- /dev/null +++ b/homeassistant/components/tessie/strings.json @@ -0,0 +1,319 @@ +{ + "config": { + "error": { + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "Enter your access token from {url}." + }, + "reauth_confirm": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "[%key:component::tessie::config::step::user::description%]", + "title": "[%key:common::config_flow::title::reauth%]" + } + } + }, + "entity": { + "device_tracker": { + "location": { + "name": "Location", + "state_attributes": { + "heading": { + "name": "Heading" + }, + "speed": { + "name": "Speed" + } + } + }, + "route": { + "name": "Route" + } + }, + "climate": { + "primary": { + "name": "[%key:component::climate::title%]", + "state_attributes": { + "preset_mode": { + "state": { + "off": "Normal", + "on": "Keep mode", + "dog": "Dog mode", + "camp": "Camp mode" + } + } + } + } + }, + "lock": { + "vehicle_state_locked": { + "name": "[%key:component::lock::title%]" + } + }, + "media_player": { + "media": { + "name": "[%key:component::media_player::title%]" + } + }, + "sensor": { + "charge_state_usable_battery_level": { + "name": "Battery level" + }, + "charge_state_charge_energy_added": { + "name": "Charge energy added" + }, + "charge_state_charger_power": { + "name": "Charger power" + }, + "charge_state_charger_voltage": { + "name": "Charger voltage" + }, + "charge_state_charger_actual_current": { + "name": "Charger current" + }, + "charge_state_charge_rate": { + "name": "Charge rate" + }, + "charge_state_battery_range": { + "name": "Battery range" + }, + "drive_state_speed": { + "name": "Speed" + }, + "drive_state_power": { + "name": "Power" + }, + "drive_state_shift_state": { + "name": "Shift state", + "state": { + "p": "Park", + "d": "Drive", + "r": "Reverse", + "n": "Neutral" + } + }, + "vehicle_state_odometer": { + "name": "Odometer" + }, + "vehicle_state_tpms_pressure_fl": { + "name": "Tire pressure front left" + }, + "vehicle_state_tpms_pressure_fr": { + "name": "Tire pressure front right" + }, + "vehicle_state_tpms_pressure_rl": { + "name": "Tire pressure rear left" + }, + "vehicle_state_tpms_pressure_rr": { + "name": "Tire pressure rear right" + }, + "climate_state_inside_temp": { + "name": "Inside temperature" + }, + "climate_state_outside_temp": { + "name": "Outside temperature" + }, + "climate_state_driver_temp_setting": { + "name": "Driver temperature setting" + }, + "climate_state_passenger_temp_setting": { + "name": "Passenger temperature setting" + } + }, + "cover": { + "windows": { + "name": "Vent windows" + }, + "charge_state_charge_port_door_open": { + "name": "Charge port door" + }, + "vehicle_state_ft": { "name": "Frunk" }, + "vehicle_state_rt": { "name": "Trunk" } + }, + "select": { + "climate_state_seat_heater_left": { + "name": "Seat heater left", + "state": { + "off": "[%key:common::state::off%]", + "low": "Low", + "medium": "Medium", + "high": "High" + } + }, + "climate_state_seat_heater_right": { + "name": "Seat heater right", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_rear_left": { + "name": "Seat heater rear left", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_rear_center": { + "name": "Seat heater rear center", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_rear_right": { + "name": "Seat heater rear right", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_third_row_left": { + "name": "Seat heater third row left", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_third_row_right": { + "name": "Seat heater third row right", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + } + }, + "binary_sensor": { + "state": { + "name": "Status" + }, + "charge_state_battery_heater_on": { + "name": "Battery heater" + }, + "charge_state_charge_enable_request": { + "name": "Charge enable request" + }, + "charge_state_charge_port_door_open": { + "name": "Charge port door" + }, + "charge_state_charging_state": { + "name": "Charging" + }, + "charge_state_preconditioning_enabled": { + "name": "Preconditioning enabled" + }, + "charge_state_scheduled_charging_pending": { + "name": "Scheduled charging pending" + }, + "charge_state_trip_charging": { + "name": "Trip charging" + }, + "climate_state_auto_seat_climate_left": { + "name": "Auto seat climate left" + }, + "climate_state_auto_seat_climate_right": { + "name": "Auto seat climate right" + }, + "climate_state_auto_steering_wheel_heater": { + "name": "Auto steering wheel heater" + }, + "climate_state_cabin_overheat_protection": { + "name": "Cabin overheat protection" + }, + "climate_state_cabin_overheat_protection_actively_cooling": { + "name": "Cabin overheat protection actively cooling" + }, + "vehicle_state_dashcam_state": { + "name": "Dashcam" + }, + "vehicle_state_is_user_present": { + "name": "User present" + }, + "vehicle_state_tpms_soft_warning_fl": { + "name": "Tire pressure warning front left" + }, + "vehicle_state_tpms_soft_warning_fr": { + "name": "Tire pressure warning front right" + }, + "vehicle_state_tpms_soft_warning_rl": { + "name": "Tire pressure warning rear left" + }, + "vehicle_state_tpms_soft_warning_rr": { + "name": "Tire pressure warning rear right" + }, + "vehicle_state_fd_window": { + "name": "Front driver window" + }, + "vehicle_state_fp_window": { + "name": "Front passenger window" + }, + "vehicle_state_rd_window": { + "name": "Rear driver window" + }, + "vehicle_state_rp_window": { + "name": "Rear passenger window" + } + }, + "button": { + "wake": { "name": "Wake" }, + "flash_lights": { "name": "Flash lights" }, + "honk": { "name": "Honk horn" }, + "trigger_homelink": { "name": "Homelink" }, + "enable_keyless_driving": { "name": "Keyless driving" }, + "boombox": { "name": "Play fart" } + }, + "switch": { + "charge_state_charge_enable_request": { + "name": "Charge" + }, + "climate_state_defrost_mode": { + "name": "Defrost mode" + }, + "vehicle_state_sentry_mode": { + "name": "Sentry mode" + }, + "vehicle_state_valet_mode": { + "name": "Valet mode" + }, + "climate_state_steering_wheel_heater": { + "name": "Steering wheel heater" + } + }, + "number": { + "charge_state_charge_current_request": { + "name": "Charge current" + }, + "charge_state_charge_limit_soc": { + "name": "Charge limit" + }, + "vehicle_state_speed_limit_mode_current_limit_mph": { + "name": "Speed limit" + } + }, + "update": { + "update": { + "name": "[%key:component::update::title%]" + } + } + } +} diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py new file mode 100644 index 00000000000000..595c44e11bea78 --- /dev/null +++ b/homeassistant/components/tessie/switch.py @@ -0,0 +1,121 @@ +"""Switch platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from tessie_api import ( + disable_sentry_mode, + disable_valet_mode, + enable_sentry_mode, + enable_valet_mode, + start_charging, + start_defrost, + start_steering_wheel_heater, + stop_charging, + stop_defrost, + stop_steering_wheel_heater, +) + +from homeassistant.components.switch import ( + SwitchDeviceClass, + 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 TessieStateUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieSwitchEntityDescription(SwitchEntityDescription): + """Describes Tessie Switch entity.""" + + on_func: Callable + off_func: Callable + + +DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( + TessieSwitchEntityDescription( + key="charge_state_charge_enable_request", + on_func=lambda: start_charging, + off_func=lambda: stop_charging, + icon="mdi:ev-station", + ), + TessieSwitchEntityDescription( + key="climate_state_defrost_mode", + on_func=lambda: start_defrost, + off_func=lambda: stop_defrost, + icon="mdi:snowflake", + ), + TessieSwitchEntityDescription( + key="vehicle_state_sentry_mode", + on_func=lambda: enable_sentry_mode, + off_func=lambda: disable_sentry_mode, + icon="mdi:shield-car", + ), + TessieSwitchEntityDescription( + key="vehicle_state_valet_mode", + on_func=lambda: enable_valet_mode, + off_func=lambda: disable_valet_mode, + icon="mdi:car-key", + ), + TessieSwitchEntityDescription( + key="climate_state_steering_wheel_heater", + on_func=lambda: start_steering_wheel_heater, + off_func=lambda: stop_steering_wheel_heater, + icon="mdi:steering", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Switch platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + TessieSwitchEntity(vehicle.state_coordinator, description) + for vehicle in data + for description in DESCRIPTIONS + if description.key in vehicle.state_coordinator.data + ] + ) + + +class TessieSwitchEntity(TessieEntity, SwitchEntity): + """Base class for Tessie Switch.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + entity_description: TessieSwitchEntityDescription + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + description: TessieSwitchEntityDescription, + ) -> None: + """Initialize the Switch.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the state of the Switch.""" + return self._value + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + await self.run(self.entity_description.on_func()) + self.set((self.entity_description.key, True)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + await self.run(self.entity_description.off_func()) + self.set((self.entity_description.key, False)) diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py new file mode 100644 index 00000000000000..1d2fb59c492bec --- /dev/null +++ b/homeassistant/components/tessie/update.py @@ -0,0 +1,87 @@ +"""Update platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import schedule_software_update + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieUpdateStatus +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Update platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieUpdateEntity(vehicle.state_coordinator) for vehicle in data + ) + + +class TessieUpdateEntity(TessieEntity, UpdateEntity): + """Tessie Updates entity.""" + + _attr_supported_features = UpdateEntityFeature.PROGRESS + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the Update.""" + super().__init__(coordinator, "update") + + @property + def supported_features(self) -> UpdateEntityFeature: + """Flag supported features.""" + if self.get("vehicle_state_software_update_status") in ( + TessieUpdateStatus.AVAILABLE, + TessieUpdateStatus.SCHEDULED, + ): + return self._attr_supported_features | UpdateEntityFeature.INSTALL + return self._attr_supported_features + + @property + def installed_version(self) -> str: + """Return the current app version.""" + # Discard build from version number + return self.coordinator.data["vehicle_state_car_version"].split(" ")[0] + + @property + def latest_version(self) -> str | None: + """Return the latest version.""" + if self.get("vehicle_state_software_update_status") in ( + TessieUpdateStatus.AVAILABLE, + TessieUpdateStatus.SCHEDULED, + TessieUpdateStatus.INSTALLING, + TessieUpdateStatus.DOWNLOADING, + TessieUpdateStatus.WIFI_WAIT, + ): + return self.get("vehicle_state_software_update_version") + return self.installed_version + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress.""" + if ( + self.get("vehicle_state_software_update_status") + == TessieUpdateStatus.INSTALLING + ): + return self.get("vehicle_state_software_update_install_perc") + return False + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + await self.run(schedule_software_update, in_seconds=0) + self.set( + ("vehicle_state_software_update_status", TessieUpdateStatus.INSTALLING) + ) diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index acc5f62a0cc57d..89fad759f8b536 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -6,7 +6,7 @@ from enum import StrEnum import logging import re -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -33,6 +33,11 @@ SERVICE_SET_VALUE, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -98,8 +103,7 @@ class TextMode(StrEnum): TEXT = "text" -@dataclass -class TextEntityDescription(EntityDescription): +class TextEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes text entities.""" native_min: int = 0 @@ -108,7 +112,16 @@ class TextEntityDescription(EntityDescription): pattern: str | None = None -class TextEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "mode", + "native_value", + "native_min", + "native_max", + "pattern", +} + + +class TextEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Text entity.""" _entity_component_unrecorded_attributes = frozenset( @@ -157,7 +170,7 @@ def state(self) -> str | None: ) return self.native_value - @property + @cached_property def mode(self) -> TextMode: """Return the mode of the entity.""" if hasattr(self, "_attr_mode"): @@ -166,7 +179,7 @@ def mode(self) -> TextMode: return self.entity_description.mode return TextMode.TEXT - @property + @cached_property def native_min(self) -> int: """Return the minimum length of the value.""" if hasattr(self, "_attr_native_min"): @@ -181,7 +194,7 @@ def min(self) -> int: """Return the minimum length of the value.""" return max(self.native_min, 0) - @property + @cached_property def native_max(self) -> int: """Return the maximum length of the value.""" if hasattr(self, "_attr_native_max"): @@ -207,7 +220,7 @@ def pattern_cmp(self) -> re.Pattern | None: self.__pattern_cmp = re.compile(self.pattern) return self.__pattern_cmp - @property + @cached_property def pattern(self) -> str | None: """Return the regex pattern that the value must match.""" if hasattr(self, "_attr_pattern"): @@ -216,7 +229,7 @@ def pattern(self) -> str | None: return self.entity_description.pattern return None - @property + @cached_property def native_value(self) -> str | None: """Return the value reported by the text.""" return self._attr_native_value diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index 772c565e9d270e..29443acaa3dec4 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -42,5 +42,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.6.0"] + "requirements": ["thermobeacon-ble==0.6.2"] } diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index b48760f773db3d..a0a07d3cb00dff 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 f814fbffbd05e6..9c5d79cc0e0013 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/thread/discovery.py b/homeassistant/components/thread/discovery.py index 3395353b7bfaff..0f2997986cb199 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -60,11 +60,7 @@ def try_decode(value: bytes | None) -> str | None: except UnicodeDecodeError: return None - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], service.properties) - + service_properties = service.properties border_agent_id = service_properties.get(b"id") model_name = try_decode(service_properties.get(b"mn")) network_name = try_decode(service_properties.get(b"nn")) @@ -121,10 +117,7 @@ def async_read_zeroconf_cache(aiozc: AsyncZeroconf) -> list[ThreadRouterDiscover # data is not fully in the cache, so ignore for now continue - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], info.properties) + service_properties = info.properties if not (xa := service_properties.get(b"xa")): _LOGGER.debug("Ignoring record without xa %s", info) @@ -189,10 +182,7 @@ async def _add_update_service(self, type_: str, name: str): return _LOGGER.debug("_add_update_service %s %s", name, service) - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], service.properties) + service_properties = service.properties # We need xa and xp, bail out if either is missing if not (xa := service_properties.get(b"xa")): diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index fbd2345fb80d59..3fb426d6b11c47 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -19,6 +19,7 @@ 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 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, + description_placeholders={"url": TOKEN_URL}, errors=errors, ) @@ -75,5 +77,6 @@ async def async_step_user( 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 8306f25f587afd..c7cef9f4657f39 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/time/__init__.py b/homeassistant/components/time/__init__.py index 26d40191fb959b..387c42f08523bd 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -1,10 +1,9 @@ """Component to allow setting time as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import time, timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -22,6 +21,12 @@ from .const import DOMAIN, SERVICE_SET_VALUE +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -62,12 +67,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class TimeEntityDescription(EntityDescription): +class TimeEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes time entities.""" -class TimeEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = {"native_value"} + + +class TimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Time entity.""" entity_description: TimeEntityDescription @@ -75,13 +82,13 @@ class TimeEntity(Entity): _attr_device_class: None = None _attr_state: None = None - @property + @cached_property @final def device_class(self) -> None: """Return the device class for the entity.""" return None - @property + @cached_property @final def state_attributes(self) -> None: """Return the state attributes.""" @@ -95,7 +102,7 @@ def state(self) -> str | None: return None return self.native_value.isoformat() - @property + @cached_property def native_value(self) -> time | None: """Return the value reported by the time.""" return self._attr_native_value diff --git a/homeassistant/components/time_date/const.py b/homeassistant/components/time_date/const.py new file mode 100644 index 00000000000000..4d0ff354a6c1c0 --- /dev/null +++ b/homeassistant/components/time_date/const.py @@ -0,0 +1,6 @@ +"""Constants for the Time & Date integration.""" +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "time_date" diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 5646c7a70184b5..c00d362428b792 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -1,20 +1,23 @@ """Support for showing the date and the time.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_DISPLAY_OPTIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) TIME_STR_FORMAT = "%H:%M" @@ -47,9 +50,26 @@ async def async_setup_platform( ) -> None: """Set up the Time and Date sensor.""" if hass.config.time_zone is None: - _LOGGER.error("Timezone is not set in Home Assistant configuration") + _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable] return False + if "beat" in config[CONF_DISPLAY_OPTIONS]: + async_create_issue( + hass, + DOMAIN, + "deprecated_beat", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_beat", + translation_placeholders={ + "config_key": "beat", + "display_options": "display_options", + "integration": DOMAIN, + }, + ) + _LOGGER.warning("'beat': is deprecated and will be removed in version 2024.7") + async_add_entities( [TimeDateSensor(hass, variable) for variable in config[CONF_DISPLAY_OPTIONS]] ) @@ -58,28 +78,28 @@ async def async_setup_platform( class TimeDateSensor(SensorEntity): """Implementation of a Time and Date sensor.""" - def __init__(self, hass, option_type): + _attr_should_poll = False + + def __init__(self, hass: HomeAssistant, option_type: str) -> None: """Initialize the sensor.""" self._name = OPTION_TYPES[option_type] self.type = option_type - self._state = None + self._state: str | None = None self.hass = hass - self.unsub = None - - self._update_internal_state(dt_util.utcnow()) + self.unsub: CALLBACK_TYPE | None = None @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._state @property - def icon(self): + def icon(self) -> str: """Icon to use in the frontend, if any.""" if "date" in self.type and "time" in self.type: return "mdi:calendar-clock" @@ -89,9 +109,16 @@ def icon(self): async def async_added_to_hass(self) -> None: """Set up first update.""" - self.unsub = async_track_point_in_utc_time( - self.hass, self.point_in_time_listener, self.get_next_interval() + + async def async_update_config(event: Event) -> None: + """Handle core config update.""" + self._update_state_and_setup_listener() + self.async_write_ha_state() + + self.async_on_remove( + self.hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, async_update_config) ) + self._update_state_and_setup_listener() async def async_will_remove_from_hass(self) -> None: """Cancel next update.""" @@ -99,29 +126,27 @@ async def async_will_remove_from_hass(self) -> None: self.unsub() self.unsub = None - def get_next_interval(self): + def get_next_interval(self, time_date: datetime) -> datetime: """Compute next time an update should occur.""" - now = dt_util.utcnow() - if self.type == "date": - tomorrow = dt_util.as_local(now) + timedelta(days=1) + tomorrow = dt_util.as_local(time_date) + timedelta(days=1) return dt_util.start_of_local_day(tomorrow) if self.type == "beat": # Add 1 hour because @0 beats is at 23:00:00 UTC. - timestamp = dt_util.as_timestamp(now + timedelta(hours=1)) + timestamp = dt_util.as_timestamp(time_date + timedelta(hours=1)) interval = 86.4 else: - timestamp = dt_util.as_timestamp(now) + timestamp = dt_util.as_timestamp(time_date) interval = 60 delta = interval - (timestamp % interval) - next_interval = now + timedelta(seconds=delta) - _LOGGER.debug("%s + %s -> %s (%s)", now, delta, next_interval, self.type) + next_interval = time_date + timedelta(seconds=delta) + _LOGGER.debug("%s + %s -> %s (%s)", time_date, delta, next_interval, self.type) return next_interval - def _update_internal_state(self, time_date): + def _update_internal_state(self, time_date: datetime) -> None: time = dt_util.as_local(time_date).strftime(TIME_STR_FORMAT) time_utc = time_date.strftime(TIME_STR_FORMAT) date = dt_util.as_local(time_date).date().isoformat() @@ -155,13 +180,20 @@ def _update_internal_state(self, time_date): self._state = f"@{beat:03d}" elif self.type == "date_time_iso": - self._state = dt_util.parse_datetime(f"{date} {time}").isoformat() + self._state = dt_util.parse_datetime( + f"{date} {time}", raise_on_error=True + ).isoformat() + + def _update_state_and_setup_listener(self) -> None: + """Update state and setup listener for next interval.""" + now = dt_util.utcnow() + self._update_internal_state(now) + self.unsub = async_track_point_in_utc_time( + self.hass, self.point_in_time_listener, self.get_next_interval(now) + ) @callback - def point_in_time_listener(self, time_date): + def point_in_time_listener(self, time_date: datetime) -> None: """Get the latest data and update state.""" - self._update_internal_state(time_date) + self._update_state_and_setup_listener() self.async_write_ha_state() - self.unsub = async_track_point_in_utc_time( - self.hass, self.point_in_time_listener, self.get_next_interval() - ) diff --git a/homeassistant/components/time_date/strings.json b/homeassistant/components/time_date/strings.json new file mode 100644 index 00000000000000..582fd44a45bf57 --- /dev/null +++ b/homeassistant/components/time_date/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "deprecated_beat": { + "title": "The `{config_key}` Time & Date sensor is being removed", + "description": "Please remove the `{config_key}` key from the `{display_options}` for the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 968256ce3d9236..afcb8e28f74d20 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 TYPE_CHECKING, Any, final import voluptuous as vol @@ -11,8 +12,14 @@ 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.exceptions import HomeAssistantError +from homeassistant.core import ( + CALLBACK_TYPE, + HomeAssistant, + ServiceCall, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -21,8 +28,24 @@ 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 ( + ATTR_DESCRIPTION, + ATTR_DUE, + ATTR_DUE_DATE, + ATTR_DUE_DATETIME, + DOMAIN, + TodoItemStatus, + TodoListEntityFeature, +) + +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property -from .const import DOMAIN, TodoItemStatus, TodoListEntityFeature _LOGGER = logging.getLogger(__name__) @@ -31,6 +54,66 @@ 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=vol.Any(cv.date, None), + todo_item_field=ATTR_DUE, + required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, + ), + TodoItemFieldDescription( + service_field=ATTR_DUE_DATETIME, + validation=vol.Any(vol.All(cv.datetime, dt_util.as_local), None), + todo_item_field=ATTR_DUE, + required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + ), + TodoItemFieldDescription( + service_field=ATTR_DESCRIPTION, + validation=vol.Any(cv.string, None), + 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 ServiceValidationError( + f"Entity does not support setting field '{desc.service_field}'", + translation_domain=DOMAIN, + translation_key="update_field_not_supported", + translation_placeholders={"service_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 +122,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 +148,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 +171,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 +220,31 @@ 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): + +CACHED_PROPERTIES_WITH_ATTR_ = { + "todo_items", +} + + +class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """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: @@ -121,7 +254,7 @@ def state(self) -> int | None: return None return sum([item.status == TodoItemStatus.NEEDS_ACTION for item in items]) - @property + @cached_property def todo_items(self) -> list[TodoItem] | None: """Return the To-do items in the To-do list.""" return self._attr_todo_items @@ -149,6 +282,102 @@ async def async_move_todo_item( """ 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 +402,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 +465,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 + }, + ) ) @@ -240,13 +484,31 @@ async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> item = call.data["item"] found = _find_by_uid_or_summary(item, entity.todo_items) if not found: - raise ValueError(f"Unable to find To-do item '{item}'") + raise ServiceValidationError( + f"Unable to find To-do item '{item}'", + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"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) + + # Perform a partial update on the existing entity based on the fields + # present in the update. This allows explicitly clearing any of the + # extended fields present and set to None. + updated_data = dataclasses.asdict(found) + if summary := call.data.get("rename"): + updated_data["summary"] = summary + if status := call.data.get("status"): + updated_data["status"] = status + updated_data.update( + { + desc.todo_item_field: call.data[desc.service_field] + for desc in TODO_ITEM_FIELDS + if desc.service_field in call.data + } ) - - await entity.async_update_todo_item(item=update_item) + await entity.async_update_todo_item(item=TodoItem(**updated_data)) async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: @@ -255,6 +517,35 @@ async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> for item in call.data.get("item", []): found = _find_by_uid_or_summary(item, entity.todo_items) if not found or not found.uid: - raise ValueError(f"Unable to find To-do item '{item}") + raise ServiceValidationError( + f"Unable to find To-do item '{item}'", + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"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 5a8a6e54e8ffdc..a605f9fcba2565 100644 --- a/homeassistant/components/todo/const.py +++ b/homeassistant/components/todo/const.py @@ -4,6 +4,11 @@ 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 00000000000000..2cce9da9c0f054 --- /dev/null +++ b/homeassistant/components/todo/intent.py @@ -0,0 +1,56 @@ +"""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, TodoItemStatus, 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) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + + slots = self.async_validate_slots(intent_obj.slots) + item = slots["item"]["value"] + list_name = slots["name"]["value"] + + 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(summary=item, status=TodoItemStatus.NEEDS_ACTION) + ) + + 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 1bdb8aca7792b4..07f91e12e22ce4 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,27 @@ add_item: example: "Submit income tax return" selector: text: + due_date: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + example: "2023-11-17" + selector: + date: + due_datetime: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + example: "2023-11-17 13:30:00" + selector: + datetime: + description: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + example: "A more complete description of the to-do item than that provided by the summary." + selector: + text: update_item: target: entity: @@ -34,6 +70,27 @@ update_item: options: - needs_action - completed + due_date: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + example: "2023-11-17" + selector: + date: + due_datetime: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + example: "2023-11-17 13:30:00" + selector: + datetime: + description: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + example: "A more complete description of the to-do item than that provided by the summary." + selector: + text: remove_item: target: entity: @@ -45,3 +102,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 6ba8aaba1a51f7..5ef7a5fe35b3fe 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.", @@ -52,5 +90,13 @@ "completed": "Completed" } } + }, + "exceptions": { + "item_not_found": { + "message": "Unable to find To-do item: {item}" + }, + "update_field_not_supported": { + "message": "Entity does not support setting field: {service_field}" + } } } diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index b8c79210dfbf95..94b4ad318265d1 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -16,7 +16,7 @@ _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 c0d3ec6e2ce8be..5067e98642ebfc 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.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,25 @@ 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] = { + "content": item.summary, + # Description needs to be empty string to be cleared + "description": item.description or "", + } + if due := item.due: + if isinstance(due, datetime.datetime): + item_data["due_datetime"] = due.isoformat() + else: + item_data["due_date"] = due.isoformat() + else: + # Special flag "no date" clears the due date/datetime. + # See https://developer.todoist.com/rest/v2/#update-a-task for more. + item_data["due_string"] = "no date" + return item_data + + class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): """A Todoist TodoListEntity.""" @@ -37,6 +58,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 +86,28 @@ def _handle_coordinator_update(self) -> None: 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 +118,7 @@ async def async_create_todo_item(self, item: TodoItem) -> None: 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,13 +126,19 @@ async def async_create_todo_item(self, item: TodoItem) -> None: 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) - else: - await self.coordinator.api.reopen_task(task_id=uid) + # Only update status if changed + for existing_item in self._attr_todo_items or (): + if existing_item.uid != item.uid: + continue + + if item.status != existing_item.status: + if item.status == TodoItemStatus.COMPLETED: + await self.coordinator.api.close_task(task_id=uid) + else: + await self.coordinator.api.reopen_task(task_id=uid) await self.coordinator.async_refresh() async def async_delete_todo_items(self, uids: list[str]) -> None: diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index 3e07392c336359..b31b5102394bb9 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -18,7 +18,7 @@ from .const import DOMAIN, FAN_TIMER_MAX, POWER_TIMER_MAX, SALT_BATH_TIMER_MAX -@dataclass +@dataclass(frozen=True) class ToloNumberEntityDescriptionBase: """Required values when describing TOLO Number entities.""" @@ -26,7 +26,7 @@ class ToloNumberEntityDescriptionBase: setter: Callable[[ToloClient, int | None], Any] -@dataclass +@dataclass(frozen=True) class ToloNumberEntityDescription( NumberEntityDescription, ToloNumberEntityDescriptionBase ): diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py index 2ff901939ae150..ec57612a99fae8 100644 --- a/homeassistant/components/tolo/sensor.py +++ b/homeassistant/components/tolo/sensor.py @@ -26,7 +26,7 @@ from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class ToloSensorEntityDescriptionBase: """Required values when describing TOLO Sensor entities.""" @@ -34,7 +34,7 @@ class ToloSensorEntityDescriptionBase: availability_checker: Callable[[SettingsInfo, StatusInfo], bool] | None -@dataclass +@dataclass(frozen=True) class ToloSensorEntityDescription( SensorEntityDescription, ToloSensorEntityDescriptionBase ): diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index da64157dad861d..d71dd45bcfe082 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -100,10 +100,10 @@ def _update_tomato_info(self): try: if self.ssl: response = requests.Session().send( - self.req, timeout=3, verify=self.verify_ssl + self.req, timeout=60, verify=self.verify_ssl ) else: - response = requests.Session().send(self.req, timeout=3) + response = requests.Session().send(self.req, timeout=60) # Calling and parsing the Tomato api here. We only need the # wldev and dhcpd_lease values. diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 947bbf6fd2f540..6b285378e7edda 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -70,7 +70,7 @@ ) -@dataclass +@dataclass(frozen=True) class TomorrowioSensorEntityDescription(SensorEntityDescription): """Describes a Tomorrow.io sensor entity.""" @@ -92,8 +92,9 @@ def __post_init__(self) -> None: ) if self.value_map is not None: - self.device_class = SensorDeviceClass.ENUM - self.options = [item.name.lower() for item in self.value_map] + options = [item.name.lower() for item in self.value_map] + object.__setattr__(self, "device_class", SensorDeviceClass.ENUM) + object.__setattr__(self, "options", options) # From https://cfpub.epa.gov/ncer_abstracts/index.cfm/fuseaction/display.files/fileID/14285 diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index e632915edf7967..6edc656df06652 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -91,14 +91,14 @@ class ToonBoilerModuleBinarySensor(ToonBinarySensor, ToonBoilerModuleDeviceEntit """Defines a Boiler module binary sensor.""" -@dataclass +@dataclass(frozen=True) class ToonBinarySensorRequiredKeysMixin(ToonRequiredKeysMixin): """Mixin for binary sensor required keys.""" cls: type[ToonBinarySensor] -@dataclass +@dataclass(frozen=True) class ToonBinarySensorEntityDescription( BinarySensorEntityDescription, ToonBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py index 75e3ddb0370d22..44986b02143c31 100644 --- a/homeassistant/components/toon/models.py +++ b/homeassistant/components/toon/models.py @@ -151,7 +151,7 @@ def device_info(self) -> DeviceInfo: ) -@dataclass +@dataclass(frozen=True) class ToonRequiredKeysMixin: """Mixin for required keys.""" diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 90dd466045cc85..7ff9d2b67f7d32 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -114,14 +114,14 @@ class ToonDisplayDeviceSensor(ToonSensor, ToonDisplayDeviceEntity): """Defines a Display sensor.""" -@dataclass +@dataclass(frozen=True) class ToonSensorRequiredKeysMixin(ToonRequiredKeysMixin): """Mixin for sensor required keys.""" cls: type[ToonSensor] -@dataclass +@dataclass(frozen=True) class ToonSensorEntityDescription(SensorEntityDescription, ToonSensorRequiredKeysMixin): """Describes Toon sensor entity.""" diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index fb05a15db00673..ed29e77a58cc9d 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -21,8 +21,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } }, "services": { diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py index bf283b203c7f92..8dddb657df016c 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -94,14 +94,14 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) -@dataclass +@dataclass(frozen=True) class ToonSwitchRequiredKeysMixin(ToonRequiredKeysMixin): """Mixin for switch required keys.""" cls: type[ToonSwitch] -@dataclass +@dataclass(frozen=True) class ToonSwitchEntityDescription(SwitchEntityDescription, ToonSwitchRequiredKeysMixin): """Describes Toon switch entity.""" diff --git a/homeassistant/components/totalconnect/const.py b/homeassistant/components/totalconnect/const.py index 5012a303b69bc5..1e98adaaa70a71 100644 --- a/homeassistant/components/totalconnect/const.py +++ b/homeassistant/components/totalconnect/const.py @@ -2,7 +2,6 @@ DOMAIN = "totalconnect" CONF_USERCODES = "usercodes" -CONF_LOCATION = "location" AUTO_BYPASS = "auto_bypass_low_battery" # Most TotalConnect alarms will work passing '-1' as usercode diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index f2a1e682304337..4efd7ffdf0bead 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -29,6 +29,7 @@ from .const import DOMAIN, PLATFORMS from .coordinator import TPLinkDataUpdateCoordinator +from .models import TPLinkData DISCOVERY_INTERVAL = timedelta(minutes=15) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -102,7 +103,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" ) - hass.data[DOMAIN][entry.entry_id] = TPLinkDataUpdateCoordinator(hass, device) + parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5)) + child_coordinators: list[TPLinkDataUpdateCoordinator] = [] + + if device.is_strip: + child_coordinators = [ + # The child coordinators only update energy data so we can + # set a longer update interval to avoid flooding the device + TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60)) + for child in device.children + ] + + hass.data[DOMAIN][entry.entry_id] = TPLinkData( + parent_coordinator, child_coordinators + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -111,7 +125,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" hass_data: dict[str, Any] = hass.data[DOMAIN] - device: SmartDevice = hass_data[entry.entry_id].device + data: TPLinkData = hass_data[entry.entry_id] + device = data.parent_coordinator.device if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass_data.pop(entry.entry_id) await device.protocol.close() diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index b1cd323a36a6f5..22b5741fceb615 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -13,7 +13,6 @@ ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" CONF_DIMMER: Final = "dimmer" -CONF_DISCOVERY: Final = "discovery" CONF_LIGHT: Final = "light" CONF_STRIP: Final = "strip" CONF_SWITCH: Final = "switch" diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 97c8397831dff1..582c49638e7c07 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -22,11 +22,10 @@ def __init__( self, hass: HomeAssistant, device: SmartDevice, + update_interval: timedelta, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device - self.update_children = True - update_interval = timedelta(seconds=5) super().__init__( hass, _LOGGER, @@ -39,19 +38,9 @@ def __init__( ), ) - async def async_request_refresh_without_children(self) -> None: - """Request a refresh without the children.""" - # If the children do get updated this is ok as this is an - # optimization to reduce the number of requests on the device - # when we do not need it. - self.update_children = False - await self.async_request_refresh() - async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" try: - await self.device.update(update_children=self.update_children) + await self.device.update(update_children=False) except SmartDeviceException as ex: raise UpdateFailed(ex) from ex - finally: - self.update_children = True diff --git a/homeassistant/components/tplink/diagnostics.py b/homeassistant/components/tplink/diagnostics.py index c81356ee658c0b..65646e8b8581db 100644 --- a/homeassistant/components/tplink/diagnostics.py +++ b/homeassistant/components/tplink/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN -from .coordinator import TPLinkDataUpdateCoordinator +from .models import TPLinkData TO_REDACT = { # Entry fields @@ -36,7 +36,8 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + data: TPLinkData = hass.data[DOMAIN][entry.entry_id] + coordinator = data.parent_coordinator oui = format_mac(coordinator.device.mac)[:8].upper() return async_redact_data( {"device_last_response": coordinator.device.internal_state, "oui": oui}, diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index afb341b47edea8..84781597b9385d 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -18,13 +18,13 @@ def async_refresh_after( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Define a wrapper to refresh after.""" async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: await func(self, *args, **kwargs) - await self.coordinator.async_request_refresh_without_children() + await self.coordinator.async_request_refresh() return _async_wrap diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index db7e6ff355e827..94bb4d287bb4c3 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -28,6 +28,7 @@ from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .models import TPLinkData _LOGGER = logging.getLogger(__name__) @@ -132,14 +133,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - if coordinator.device.is_light_strip: + data: TPLinkData = hass.data[DOMAIN][config_entry.entry_id] + parent_coordinator = data.parent_coordinator + device = parent_coordinator.device + if device.is_light_strip: async_add_entities( - [ - TPLinkSmartLightStrip( - cast(SmartLightStrip, coordinator.device), coordinator - ) - ] + [TPLinkSmartLightStrip(cast(SmartLightStrip, device), parent_coordinator)] ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -152,9 +151,9 @@ async def async_setup_entry( SEQUENCE_EFFECT_DICT, "async_set_sequence_effect", ) - elif coordinator.device.is_bulb or coordinator.device.is_dimmer: + elif device.is_bulb or device.is_dimmer: async_add_entities( - [TPLinkSmartBulb(cast(SmartBulb, coordinator.device), coordinator)] + [TPLinkSmartBulb(cast(SmartBulb, device), parent_coordinator)] ) @@ -182,6 +181,19 @@ def __init__( self._attr_unique_id = legacy_device_id(device) else: self._attr_unique_id = device.mac.replace(":", "").upper() + modes: set[ColorMode] = set() + if device.is_variable_color_temp: + modes.add(ColorMode.COLOR_TEMP) + temp_range = device.valid_temperature_range + self._attr_min_color_temp_kelvin = temp_range.min + self._attr_max_color_temp_kelvin = temp_range.max + if device.is_color: + modes.add(ColorMode.HS) + if device.is_dimmable: + modes.add(ColorMode.BRIGHTNESS) + if not modes: + modes.add(ColorMode.ONOFF) + self._attr_supported_color_modes = modes @callback def _async_extract_brightness_transition( @@ -241,16 +253,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: transition = int(transition * 1_000) await self.device.turn_off(transition=transition) - @property - def min_color_temp_kelvin(self) -> int: - """Return minimum supported color temperature.""" - return cast(int, self.device.valid_temperature_range.min) - - @property - def max_color_temp_kelvin(self) -> int: - """Return maximum supported color temperature.""" - return cast(int, self.device.valid_temperature_range.max) - @property def color_temp_kelvin(self) -> int: """Return the color temperature of this light.""" @@ -267,22 +269,6 @@ def hs_color(self) -> tuple[int, int] | None: hue, saturation, _ = self.device.hsv return hue, saturation - @property - def supported_color_modes(self) -> set[ColorMode]: - """Return list of available color modes.""" - modes: set[ColorMode] = set() - if self.device.is_variable_color_temp: - modes.add(ColorMode.COLOR_TEMP) - if self.device.is_color: - modes.add(ColorMode.HS) - if self.device.is_dimmable: - modes.add(ColorMode.BRIGHTNESS) - - if not modes: - modes.add(ColorMode.ONOFF) - - return modes - @property def color_mode(self) -> ColorMode: """Return the active color mode.""" @@ -300,11 +286,7 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): """Representation of a TPLink Smart Light Strip.""" device: SmartLightStrip - - @property - def supported_features(self) -> LightEntityFeature: - """Flag supported features.""" - return super().supported_features | LightEntityFeature.EFFECT + _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT @property def effect_list(self) -> list[str] | None: diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index e0ac41bdec6f73..162344f04ec20b 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/models.py b/homeassistant/components/tplink/models.py new file mode 100644 index 00000000000000..4367f46711dc89 --- /dev/null +++ b/homeassistant/components/tplink/models.py @@ -0,0 +1,14 @@ +"""The tplink integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .coordinator import TPLinkDataUpdateCoordinator + + +@dataclass(slots=True) +class TPLinkData: + """Data for the tplink integration.""" + + parent_coordinator: TPLinkDataUpdateCoordinator + children_coordinators: list[TPLinkDataUpdateCoordinator] diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 46909f39dfec30..e5f7ae332ec60a 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -33,9 +33,10 @@ ) from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity +from .models import TPLinkData -@dataclass +@dataclass(frozen=True) class TPLinkSensorEntityDescription(SensorEntityDescription): """Describes TPLink sensor entity.""" @@ -106,31 +107,39 @@ def async_emeter_from_device( return None if device.is_bulb else 0.0 +def _async_sensors_for_device( + device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator +) -> list[SmartPlugSensor]: + """Generate the sensors for the device.""" + return [ + SmartPlugSensor(device, coordinator, description) + for description in ENERGY_SENSORS + if async_emeter_from_device(device, description) is not None + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors.""" - coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + data: TPLinkData = hass.data[DOMAIN][config_entry.entry_id] + parent_coordinator = data.parent_coordinator + children_coordinators = data.children_coordinators entities: list[SmartPlugSensor] = [] - parent = coordinator.device + parent = parent_coordinator.device if not parent.has_emeter: return - def _async_sensors_for_device(device: SmartDevice) -> list[SmartPlugSensor]: - return [ - SmartPlugSensor(device, coordinator, description) - for description in ENERGY_SENSORS - if async_emeter_from_device(device, description) is not None - ] - if parent.is_strip: # Historically we only add the children if the device is a strip - for child in parent.children: - entities.extend(_async_sensors_for_device(child)) + for idx, child in enumerate(parent.children): + entities.extend( + _async_sensors_for_device(child, children_coordinators[idx]) + ) else: - entities.extend(_async_sensors_for_device(parent)) + entities.extend(_async_sensors_for_device(parent, parent_coordinator)) async_add_entities(entities) diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 750d422cd0df2a..3b4024c07b4140 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/switch.py b/homeassistant/components/tplink/switch.py index fb812abc2933da..b1ca848260f040 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -16,6 +16,7 @@ from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .models import TPLinkData _LOGGER = logging.getLogger(__name__) @@ -26,8 +27,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - device = cast(SmartPlug, coordinator.device) + data: TPLinkData = hass.data[DOMAIN][config_entry.entry_id] + parent_coordinator = data.parent_coordinator + device = cast(SmartPlug, parent_coordinator.device) if not device.is_plug and not device.is_strip and not device.is_dimmer: return entities: list = [] @@ -35,11 +37,11 @@ async def async_setup_entry( # Historically we only add the children if the device is a strip _LOGGER.debug("Initializing strip with %s sockets", len(device.children)) for child in device.children: - entities.append(SmartPlugSwitchChild(device, coordinator, child)) + entities.append(SmartPlugSwitchChild(device, parent_coordinator, child)) elif device.is_plug: - entities.append(SmartPlugSwitch(device, coordinator)) + entities.append(SmartPlugSwitch(device, parent_coordinator)) - entities.append(SmartPlugLedSwitch(device, coordinator)) + entities.append(SmartPlugLedSwitch(device, parent_coordinator)) async_add_entities(entities) diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 6da32cd0c1a87e..04fa6d162d3cf8 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/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 300d7ebafc7fd6..8dd0ed8e91b843 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -24,11 +24,8 @@ 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 @@ 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 @@ async def _listen(self) -> None: ): 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 @@ def _send_hardware_update(self, event: dict[str, Any]) -> None: 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 254a8c274f3df0..acb4f6f7487cd3 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -26,9 +26,16 @@ 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/sensor.py b/homeassistant/components/tractive/sensor.py index 49eda4f8d09dcc..ab9dad88e0689c 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -43,14 +43,14 @@ from .entity import TractiveEntity -@dataclass +@dataclass(frozen=True) class TractiveRequiredKeysMixin: """Mixin for required keys.""" signal_prefix: str -@dataclass +@dataclass(frozen=True) class TractiveSensorEntityDescription( SensorEntityDescription, TractiveRequiredKeysMixin ): diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 55acdb9bdcd8a8..b77c35e6904ba4 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -21,21 +21,21 @@ CLIENT, DOMAIN, TRACKABLES, - TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_SWITCH_STATUS_UPDATED, ) from .entity import TractiveEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TractiveRequiredKeysMixin: """Mixin for required keys.""" method: Literal["async_set_buzzer", "async_set_led", "async_set_live_tracking"] -@dataclass +@dataclass(frozen=True) class TractiveSwitchEntityDescription( SwitchEntityDescription, TractiveRequiredKeysMixin ): @@ -99,11 +99,10 @@ def __init__( 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 @@ def __init__( @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/base_class.py b/homeassistant/components/tradfri/base_class.py index 416eb175d31df1..abb35df62aa89f 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -19,7 +19,7 @@ def handle_error( - func: Callable[[Command | list[Command]], Any] + func: Callable[[Command | list[Command]], Any], ) -> Callable[[Command | list[Command]], Coroutine[Any, Any, None]]: """Handle tradfri api call error.""" diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index c41b24a26473b7..5c0f05004bae7d 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -119,8 +119,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 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/sensor.py b/homeassistant/components/tradfri/sensor.py index 383eec8a8fbb51..7f04b8aff03b25 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -37,14 +37,14 @@ from .coordinator import TradfriDeviceDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class TradfriSensorEntityDescriptionMixin: """Mixin for required keys.""" value: Callable[[Device], Any | None] -@dataclass +@dataclass(frozen=True) class TradfriSensorEntityDescription( SensorEntityDescription, TradfriSensorEntityDescriptionMixin, diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 0a9a86bd23a209..69a28a567ab0ff 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 d9d28cfe13b7ac..f0f758272f764a 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -6,12 +6,12 @@ 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, CONF_LOCATION from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_LOCATION, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS from .coordinator import TVDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -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/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py index c9da5bd5d8ab9b..b725f6d2f95aec 100644 --- a/homeassistant/components/trafikverket_camera/binary_sensor.py +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -19,14 +19,14 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Trafikverket Camera base description keys.""" value_fn: Callable[[CameraData], bool | None] -@dataclass +@dataclass(frozen=True) class TVCameraSensorEntityDescription( BinarySensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index e75bc0bfa3000a..a5257455e7ac6a 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -14,18 +14,18 @@ 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, CONF_LOCATION 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 TextSelector -from .const import CONF_LOCATION, DOMAIN +from .const import 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 @@ async def validate_input( 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 @@ async def async_step_reauth_confirm( 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( @@ -95,7 +90,7 @@ async def async_step_reauth_confirm( step_id="reauth_confirm", data_schema=vol.Schema( { - vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_API_KEY): TextSelector(), } ), errors=errors, @@ -121,18 +116,15 @@ async def async_step_user( 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( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_LOCATION): cv.string, + vol.Required(CONF_API_KEY): TextSelector(), + vol.Required(CONF_LOCATION): TextSelector(), } ), errors=errors, diff --git a/homeassistant/components/trafikverket_camera/const.py b/homeassistant/components/trafikverket_camera/const.py index ff40d1bbc919e5..728ba9f7bd5f50 100644 --- a/homeassistant/components/trafikverket_camera/const.py +++ b/homeassistant/components/trafikverket_camera/const.py @@ -2,7 +2,6 @@ from homeassistant.const import Platform DOMAIN = "trafikverket_camera" -CONF_LOCATION = "location" PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] ATTRIBUTION = "Data provided by Trafikverket" diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py index eb5a047ca733e2..8270fecd487ed7 100644 --- a/homeassistant/components/trafikverket_camera/coordinator.py +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -15,13 +15,13 @@ 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 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: ) 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 a679bd27d506df..d7631ada680633 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.8"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py index 96231bba755733..678c703307cbac 100644 --- a/homeassistant/components/trafikverket_camera/sensor.py +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -23,14 +23,14 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Trafikverket Camera base description keys.""" value_fn: Callable[[CameraData], StateType | datetime] -@dataclass +@dataclass(frozen=True) class TVCameraSensorEntityDescription( SensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index 651225934cd587..35dbbb1f540a07 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -15,6 +15,9 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "location": "[%key:common::config_flow::data::location%]" + }, + "data_description": { + "location": "Equal or part of name, description or camera id" } } } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index a62c05a9baf421..e1c86038986d63 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.8"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index a673f624a47c21..cd0682c12bcf39 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -32,7 +32,7 @@ SCAN_INTERVAL = timedelta(minutes=5) -@dataclass +@dataclass(frozen=True) class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" @@ -40,7 +40,7 @@ class TrafikverketRequiredKeysMixin: info_fn: Callable[[dict[str, Any]], StateType | list] | None -@dataclass +@dataclass(frozen=True) class TrafikverketSensorEntityDescription( SensorEntityDescription, TrafikverketRequiredKeysMixin ): diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index b7808dc38b29a4..df05942add10b6 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -9,7 +9,6 @@ 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 ea852ab7fdf1a6..d5402e44ec6d2f 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -8,7 +8,6 @@ from pytrafikverket import TrafikverketTrain from pytrafikverket.exceptions import ( InvalidAuthentication, - MultipleTrainAnnouncementFound, NoTrainAnnouncementFound, UnknownError, ) @@ -40,6 +39,8 @@ class TrainData: other_info: str | None deviation: str | None product_filter: str | None + departure_time_next: datetime | None + departure_time_next_next: datetime | None _LOGGER = logging.getLogger(__name__) @@ -92,6 +93,7 @@ async def _async_update_data(self) -> TrainData: when = dt_util.now() state: TrainStop | None = None + states: list[TrainStop] | None = None if self._time: departure_day = next_departuredate(self._weekdays) when = datetime.combine( @@ -105,20 +107,37 @@ async def _async_update_data(self) -> TrainData: self.from_station, self.to_station, when, self._filter_product ) else: - state = await self._train_api.async_get_next_train_stop( - self.from_station, self.to_station, when, self._filter_product + states = await self._train_api.async_get_next_train_stops( + self.from_station, + self.to_station, + when, + self._filter_product, + number_of_stops=3, ) except InvalidAuthentication as error: raise ConfigEntryAuthFailed from error except ( NoTrainAnnouncementFound, - MultipleTrainAnnouncementFound, UnknownError, ) as error: raise UpdateFailed( f"Train departure {when} encountered a problem: {error}" ) from error + depart_next = None + depart_next_next = None + if not state and states: + state = states[0] + depart_next = ( + states[1].advertised_time_at_location if len(states) > 1 else None + ) + depart_next_next = ( + states[2].advertised_time_at_location if len(states) > 2 else None + ) + + if not state: + raise UpdateFailed("Could not find any departures") + departure_time = state.advertised_time_at_location if state.estimated_time_at_location: departure_time = state.estimated_time_at_location @@ -127,7 +146,7 @@ async def _async_update_data(self) -> TrainData: delay_time = state.get_delay_time() - states = TrainData( + return TrainData( departure_time=_get_as_utc(departure_time), departure_state=state.get_state().value, cancelled=state.canceled, @@ -138,6 +157,6 @@ async def _async_update_data(self) -> TrainData: other_info=_get_as_joined(state.other_information), deviation=_get_as_joined(state.deviations), product_filter=self._filter_product, + departure_time_next=_get_as_utc(depart_next), + departure_time_next_next=_get_as_utc(depart_next_next), ) - - return states diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 8c23cb02258fb7..83dd0e726ee2d7 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.8"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index a5e76299b615a6..68865a64cb5af3 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -25,14 +25,14 @@ ATTR_PRODUCT_FILTER = "product_filter" -@dataclass +@dataclass(frozen=True) class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[TrainData], StateType | datetime] -@dataclass +@dataclass(frozen=True) class TrafikverketSensorEntityDescription( SensorEntityDescription, TrafikverketRequiredKeysMixin ): @@ -105,6 +105,20 @@ class TrafikverketSensorEntityDescription( icon="mdi:alert", value_fn=lambda data: data.deviation, ), + TrafikverketSensorEntityDescription( + key="departure_time_next", + translation_key="departure_time_next", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.departure_time_next, + ), + TrafikverketSensorEntityDescription( + key="departure_time_next_next", + translation_key="departure_time_next_next", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.departure_time_next_next, + ), ) diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 78d69c880ae744..89542211a92477 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": { @@ -70,6 +69,22 @@ } } }, + "departure_time_next": { + "name": "Departure time next", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, + "departure_time_next_next": { + "name": "Departure time next after", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, "departure_state": { "name": "Departure state", "state": { diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index f8f8629804531c..89cbd373665d2b 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 @@ async def async_step_user( ), 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 0d4680e9b37397..34c18359ee4c40 100644 --- a/homeassistant/components/trafikverket_weatherstation/const.py +++ b/homeassistant/components/trafikverket_weatherstation/const.py @@ -5,13 +5,3 @@ 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 d13eda72835cb6..1f27346b3a8ed9 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.8"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 3ec7d137b6e302..9c025237187ae3 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.const import ( DEGREE, PERCENTAGE, + UnitOfLength, UnitOfSpeed, UnitOfTemperature, UnitOfVolumetricFlux, @@ -24,70 +27,47 @@ 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", ] -@dataclass +@dataclass(frozen=True) class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" - api_key: str + value_fn: Callable[[WeatherStationInfo], StateType | datetime] -@dataclass +@dataclass(frozen=True) class TrafikverketSensorEntityDescription( SensorEntityDescription, TrafikverketRequiredKeysMixin ): """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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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 @@ class 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", + key="measure_time", + translation_key="measure_time", + value_fn=lambda data: data.measure_time, + icon="mdi:clock", entity_registry_enabled_default=False, - options=PRECIPITATION_AMOUNTNAME, - device_class=SensorDeviceClass.ENUM, + device_class=SensorDeviceClass.TIMESTAMP, ), TrafikverketSensorEntityDescription( - key="measure_time", - translation_key="measure_time", - api_key="measure_time", + 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 @@ def __init__( @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 9ff1b077f33b92..a4838dab0e2e1e 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/sensor.py b/homeassistant/components/transmission/sensor.py index 20f4fc95c8719f..87bcb87da9a77f 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -1,7 +1,9 @@ """Support for monitoring the Transmission BitTorrent client API.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress +from dataclasses import dataclass from typing import Any from transmission_rpc.torrent import Torrent @@ -16,6 +18,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 from .const import ( @@ -28,23 +31,103 @@ ) 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" +MODES: dict[str, list[str] | None] = { + "started_torrents": ["downloading"], + "completed_torrents": ["seeding"], + "paused_torrents": ["stopped"], + "active_torrents": [ + "seeding", + "downloading", + ], + "total_torrents": None, +} + + +@dataclass(frozen=True, kw_only=True) +class TransmissionSensorEntityDescription(SensorEntityDescription): + """Entity description class for Transmission sensors.""" + + val_func: Callable[[TransmissionDataUpdateCoordinator], StateType] + extra_state_attr_func: Callable[[Any], dict[str, str]] | None = None + + +SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( + TransmissionSensorEntityDescription( + key="download", + translation_key="download_speed", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + val_func=lambda coordinator: float(coordinator.data.download_speed), + ), + TransmissionSensorEntityDescription( + key="upload", + translation_key="upload_speed", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + val_func=lambda coordinator: float(coordinator.data.upload_speed), + ), + TransmissionSensorEntityDescription( + key="status", + translation_key="transmission_status", + device_class=SensorDeviceClass.ENUM, + options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], + val_func=lambda coordinator: get_state( + coordinator.data.upload_speed, coordinator.data.download_speed + ), + ), + TransmissionSensorEntityDescription( + key="active_torrents", + translation_key="active_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: coordinator.data.active_torrent_count, + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="active_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="paused_torrents", + translation_key="paused_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: coordinator.data.paused_torrent_count, + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="paused_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="total_torrents", + translation_key="total_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: coordinator.data.torrent_count, + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="total_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="completed_torrents", + translation_key="completed_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: len( + _filter_torrents(coordinator.torrents, MODES["completed_torrents"]) + ), + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="completed_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="started_torrents", + translation_key="started_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: len( + _filter_torrents(coordinator.torrents, MODES["started_torrents"]) + ), + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="started_torrents" + ), ), - SensorEntityDescription(key="started_torrents", translation_key="started_torrents"), ) @@ -59,22 +142,9 @@ async def async_setup_entry( config_entry.entry_id ] - entities: list[TransmissionSensor] = [] - - entities = [ - 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) + async_add_entities( + TransmissionSensor(coordinator, description) for description in SENSOR_TYPES + ) class TransmissionSensor( @@ -82,12 +152,13 @@ class TransmissionSensor( ): """A base class for all Transmission sensors.""" + entity_description: TransmissionSensorEntityDescription _attr_has_entity_name = True def __init__( self, coordinator: TransmissionDataUpdateCoordinator, - entity_description: SensorEntityDescription, + entity_description: TransmissionSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -101,85 +172,28 @@ def __init__( manufacturer="Transmission", ) - -class TransmissionSpeedSensor(TransmissionSensor): - """Representation of a Transmission speed sensor.""" - - _attr_device_class = SensorDeviceClass.DATA_RATE - _attr_native_unit_of_measurement = UnitOfDataRate.BYTES_PER_SECOND - _attr_suggested_display_precision = 2 - _attr_suggested_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND - - @property - def native_value(self) -> float: - """Return the speed of the sensor.""" - data = self.coordinator.data - return ( - float(data.download_speed) - if self.entity_description.key == "download" - else float(data.upload_speed) - ) - - -class TransmissionStatusSensor(TransmissionSensor): - """Representation of a Transmission status sensor.""" - - _attr_device_class = SensorDeviceClass.ENUM - _attr_options = [STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING] - - @property - def native_value(self) -> str: - """Return the value of the status sensor.""" - upload = self.coordinator.data.upload_speed - download = self.coordinator.data.download_speed - if upload > 0 and download > 0: - return STATE_UP_DOWN - if upload > 0 and download == 0: - return STATE_SEEDING - if upload == 0 and download > 0: - return STATE_DOWNLOADING - return STATE_IDLE - - -class TransmissionTorrentsSensor(TransmissionSensor): - """Representation of a Transmission torrents sensor.""" - - MODES: dict[str, list[str] | None] = { - "started_torrents": ["downloading"], - "completed_torrents": ["seeding"], - "paused_torrents": ["stopped"], - "active_torrents": [ - "seeding", - "downloading", - ], - "total_torrents": None, - } - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return "Torrents" + def native_value(self) -> StateType: + """Return the value of the sensor.""" + return self.entity_description.val_func(self.coordinator) @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes, if any.""" - info = _torrents_info( - torrents=self.coordinator.torrents, - order=self.coordinator.order, - limit=self.coordinator.limit, - statuses=self.MODES[self.entity_description.key], - ) - return { - STATE_ATTR_TORRENT_INFO: info, - } + if attr_func := self.entity_description.extra_state_attr_func: + return attr_func(self.coordinator) + return None - @property - def native_value(self) -> int: - """Return the count of the sensor.""" - torrents = _filter_torrents( - self.coordinator.torrents, statuses=self.MODES[self.entity_description.key] - ) - return len(torrents) + +def get_state(upload: int, download: int) -> str: + """Get current download/upload state.""" + if upload > 0 and download > 0: + return STATE_UP_DOWN + if upload > 0 and download == 0: + return STATE_SEEDING + if upload == 0 and download > 0: + return STATE_DOWNLOADING + return STATE_IDLE def _filter_torrents( @@ -192,13 +206,13 @@ def _filter_torrents( ] -def _torrents_info( - torrents: list[Torrent], order: str, limit: int, statuses: list[str] | None = None +def _torrents_info_attr( + coordinator: TransmissionDataUpdateCoordinator, key: str ) -> dict[str, Any]: infos = {} - torrents = _filter_torrents(torrents, statuses) - torrents = SUPPORTED_ORDER_MODES[order](torrents) - for torrent in torrents[:limit]: + torrents = _filter_torrents(coordinator.torrents, MODES[key]) + torrents = SUPPORTED_ORDER_MODES[coordinator.order](torrents) + for torrent in torrents[: coordinator.limit]: info = infos[torrent.name] = { "added_date": torrent.added_date, "percent_done": f"{torrent.percent_done * 100:.2f}", @@ -207,4 +221,4 @@ def _torrents_info( } with suppress(ValueError): info["eta"] = str(torrent.eta) - return infos + return {STATE_ATTR_TORRENT_INFO: infos} diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index fecda94fbf8a9a..643b2f0ba70c78 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -17,7 +17,7 @@ _LOGGING = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TransmissionSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -26,7 +26,7 @@ class TransmissionSwitchEntityDescriptionMixin: off_func: Callable[[TransmissionDataUpdateCoordinator], None] -@dataclass +@dataclass(frozen=True) class TransmissionSwitchEntityDescription( SwitchEntityDescription, TransmissionSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index b583f424da1849..91d50bcc928edc 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -1,5 +1,27 @@ """A sensor that monitors trends in other components.""" +from __future__ import annotations +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform +from homeassistant.core import HomeAssistant PLATFORMS = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Trend from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an Trend options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 2d00f35202c871..c86fb65e96617f 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -17,6 +17,7 @@ BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, @@ -52,23 +53,43 @@ CONF_INVERT, CONF_MAX_SAMPLES, CONF_MIN_GRADIENT, + CONF_MIN_SAMPLES, CONF_SAMPLE_DURATION, + DEFAULT_MAX_SAMPLES, + DEFAULT_MIN_GRADIENT, + DEFAULT_MIN_SAMPLES, + DEFAULT_SAMPLE_DURATION, DOMAIN, ) _LOGGER = logging.getLogger(__name__) -SENSOR_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_INVERT, default=False): cv.boolean, - vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, - vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), - vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, - } + +def _validate_min_max(data: dict[str, Any]) -> dict[str, Any]: + if ( + CONF_MIN_SAMPLES in data + and CONF_MAX_SAMPLES in data + and data[CONF_MAX_SAMPLES] < data[CONF_MIN_SAMPLES] + ): + raise vol.Invalid("min_samples must be smaller than or equal to max_samples") + return data + + +SENSOR_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, + vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, + vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), + vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, + vol.Optional(CONF_MIN_SAMPLES, default=2): cv.positive_int, + } + ), + _validate_min_max, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -85,37 +106,52 @@ async def async_setup_platform( """Set up the trend sensors.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - sensors = [] - - for device_id, device_config in config[CONF_SENSORS].items(): - entity_id = device_config[ATTR_ENTITY_ID] - attribute = device_config.get(CONF_ATTRIBUTE) - device_class = device_config.get(CONF_DEVICE_CLASS) - friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device_id) - invert = device_config[CONF_INVERT] - max_samples = device_config[CONF_MAX_SAMPLES] - min_gradient = device_config[CONF_MIN_GRADIENT] - sample_duration = device_config[CONF_SAMPLE_DURATION] - - sensors.append( + entities = [] + for sensor_name, sensor_config in config[CONF_SENSORS].items(): + entities.append( SensorTrend( - hass, - device_id, - friendly_name, - entity_id, - attribute, - device_class, - invert, - max_samples, - min_gradient, - sample_duration, + name=sensor_config.get(CONF_FRIENDLY_NAME, sensor_name), + entity_id=sensor_config[CONF_ENTITY_ID], + attribute=sensor_config.get(CONF_ATTRIBUTE), + invert=sensor_config[CONF_INVERT], + sample_duration=sensor_config[CONF_SAMPLE_DURATION], + min_gradient=sensor_config[CONF_MIN_GRADIENT], + min_samples=sensor_config[CONF_MIN_SAMPLES], + max_samples=sensor_config[CONF_MAX_SAMPLES], + device_class=sensor_config.get(CONF_DEVICE_CLASS), + sensor_entity_id=generate_entity_id( + ENTITY_ID_FORMAT, sensor_name, hass=hass + ), ) ) - if not sensors: - _LOGGER.error("No sensors added") - return - async_add_entities(sensors) + async_add_entities(entities) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up trend sensor from config entry.""" + + async_add_entities( + [ + SensorTrend( + name=entry.title, + entity_id=entry.options[CONF_ENTITY_ID], + attribute=entry.options.get(CONF_ATTRIBUTE), + invert=entry.options[CONF_INVERT], + sample_duration=entry.options.get( + CONF_SAMPLE_DURATION, DEFAULT_SAMPLE_DURATION + ), + min_gradient=entry.options.get(CONF_MIN_GRADIENT, DEFAULT_MIN_GRADIENT), + min_samples=entry.options.get(CONF_MIN_SAMPLES, DEFAULT_MIN_SAMPLES), + max_samples=entry.options.get(CONF_MAX_SAMPLES, DEFAULT_MAX_SAMPLES), + unique_id=entry.entry_id, + ) + ] + ) class SensorTrend(BinarySensorEntity, RestoreEntity): @@ -127,28 +163,33 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): def __init__( self, - hass: HomeAssistant, - device_id: str, - friendly_name: str, + name: str, entity_id: str, - attribute: str, - device_class: BinarySensorDeviceClass, + attribute: str | None, invert: bool, - max_samples: int, - min_gradient: float, sample_duration: int, + min_gradient: float, + min_samples: int, + max_samples: int, + unique_id: str | None = None, + device_class: BinarySensorDeviceClass | None = None, + sensor_entity_id: str | None = None, ) -> None: """Initialize the sensor.""" - self._hass = hass - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - self._attr_name = friendly_name - self._attr_device_class = device_class self._entity_id = entity_id self._attribute = attribute self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient - self.samples: deque = deque(maxlen=max_samples) + self._min_samples = min_samples + self.samples: deque = deque(maxlen=int(max_samples)) + + self._attr_name = name + self._attr_device_class = device_class + self._attr_unique_id = unique_id + + if sensor_entity_id: + self.entity_id = sensor_entity_id @property def is_on(self) -> bool | None: @@ -210,7 +251,7 @@ async def async_update(self) -> None: while self.samples and self.samples[0][0] < cutoff: self.samples.popleft() - if len(self.samples) < 2: + if len(self.samples) < self._min_samples: return # Calculate gradient of linear trend diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py new file mode 100644 index 00000000000000..457522dca82a83 --- /dev/null +++ b/homeassistant/components/trend/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for Trend integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_NAME, UnitOfTime +from homeassistant.helpers import selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) + +from .const import ( + CONF_INVERT, + CONF_MAX_SAMPLES, + CONF_MIN_GRADIENT, + CONF_MIN_SAMPLES, + CONF_SAMPLE_DURATION, + DEFAULT_MAX_SAMPLES, + DEFAULT_MIN_GRADIENT, + DEFAULT_MIN_SAMPLES, + DEFAULT_SAMPLE_DURATION, + DOMAIN, +) + + +async def get_base_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Get base options schema.""" + return vol.Schema( + { + vol.Optional(CONF_ATTRIBUTE): selector.AttributeSelector( + selector.AttributeSelectorConfig( + entity_id=handler.options[CONF_ENTITY_ID] + ) + ), + vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), + } + ) + + +async def get_extended_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Get extended options schema.""" + return (await get_base_options_schema(handler)).extend( + { + vol.Optional( + CONF_MAX_SAMPLES, default=DEFAULT_MAX_SAMPLES + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=2, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Optional( + CONF_MIN_SAMPLES, default=DEFAULT_MIN_SAMPLES + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=2, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Optional( + CONF_MIN_GRADIENT, default=DEFAULT_MIN_GRADIENT + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + step="any", + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Optional( + CONF_SAMPLE_DURATION, default=DEFAULT_SAMPLE_DURATION + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + mode=selector.NumberSelectorMode.BOX, + unit_of_measurement=UnitOfTime.SECONDS, + ), + ), + } + ) + + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): selector.TextSelector(), + vol.Required(CONF_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig(domain=SENSOR_DOMAIN, multiple=False), + ), + } +) + + +class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for Trend.""" + + config_flow = { + "user": SchemaFlowFormStep(schema=CONFIG_SCHEMA, next_step="settings"), + "settings": SchemaFlowFormStep(get_base_options_schema), + } + options_flow = { + "init": SchemaFlowFormStep(get_extended_options_schema), + } + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/trend/const.py b/homeassistant/components/trend/const.py index 6787dc08445f4a..838056bfc4d3a2 100644 --- a/homeassistant/components/trend/const.py +++ b/homeassistant/components/trend/const.py @@ -12,3 +12,9 @@ CONF_MAX_SAMPLES = "max_samples" CONF_MIN_GRADIENT = "min_gradient" CONF_SAMPLE_DURATION = "sample_duration" +CONF_MIN_SAMPLES = "min_samples" + +DEFAULT_MAX_SAMPLES = 2 +DEFAULT_MIN_SAMPLES = 2 +DEFAULT_MIN_GRADIENT = 0.0 +DEFAULT_SAMPLE_DURATION = 0 diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 0adbf62334640d..110bab99e52266 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,9 @@ "domain": "trend", "name": "Trend", "codeowners": ["@jpbede"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trend", + "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", "requirements": ["numpy==1.26.0"] diff --git a/homeassistant/components/trend/strings.json b/homeassistant/components/trend/strings.json index 6af231bb4c537b..2fe0b35ee3cb21 100644 --- a/homeassistant/components/trend/strings.json +++ b/homeassistant/components/trend/strings.json @@ -4,5 +4,43 @@ "name": "[%key:common::action::reload%]", "description": "Reloads trend sensors from the YAML-configuration." } + }, + "config": { + "step": { + "user": { + "title": "Trend helper", + "description": "The trend helper allows you to create a sensor which show the trend of a numeric state or a state attribute from another entity.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "Entity that this sensor tracks" + } + }, + "settings": { + "data": { + "attribute": "Attribute of entity that this sensor tracks", + "invert": "Invert the result" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "[%key:component::trend::config::step::settings::data::attribute%]", + "invert": "[%key:component::trend::config::step::settings::data::invert%]", + "max_samples": "Maximum number of stored samples", + "min_samples": "Minimum number of stored samples", + "min_gradient": "Minimum rate at which the value must be changing", + "sample_duration": "Duration in seconds to store samples for" + }, + "data_description": { + "max_samples": "The maximum number of samples to store. If the number of samples exceeds this value, the oldest samples will be discarded.", + "min_samples": "The minimum number of samples that must be collected before the gradient can be calculated.", + "min_gradient": "The minimum rate at which the observed value must be changing for this sensor to switch on. The gradient is measured in sensor units per second.", + "sample_duration": "The duration in seconds to store samples for. Samples older than this value will be discarded." + } + } + } } } diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 38715825875b99..9a44382e8513e0 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -545,7 +545,7 @@ def async_register_legacy_engine( self.providers[engine] = provider self.hass.config.components.add( - PLATFORM_FORMAT.format(domain=engine, platform=DOMAIN) + PLATFORM_FORMAT.format(domain=DOMAIN, platform=engine) ) @callback diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 4734c3f22d15f1..05be2e284e3361 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -6,7 +6,7 @@ from functools import partial import logging from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -18,6 +18,7 @@ SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.config import config_per_platform from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DESCRIPTION, @@ -25,12 +26,12 @@ CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_CACHE, @@ -103,8 +104,8 @@ async def async_setup_legacy( # Load service descriptions from tts/services.yaml services_yaml = Path(__file__).parent / "services.yaml" - services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_setup_platform( diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index 338a8c35003c89..f379dc01deeace 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -2,7 +2,7 @@ "domain": "tts", "name": "Text-to-speech (TTS)", "after_dependencies": ["media_player"], - "codeowners": ["@home-assistant/core", "@pvizeli"], + "codeowners": ["@home-assistant/core"], "dependencies": ["http", "ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/tts", "integration_type": "entity", diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 276d21f3821947..ee084b77ef1de8 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -15,6 +15,7 @@ ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -25,10 +26,7 @@ CONF_ACCESS_SECRET, CONF_APP_TYPE, CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, CONF_ENDPOINT, - CONF_PASSWORD, - CONF_USERNAME, DOMAIN, LOGGER, PLATFORMS, diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index c57a37365ed87e..8e934ae6593a24 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -21,7 +21,7 @@ from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode -@dataclass +@dataclass(frozen=True) class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Tuya binary sensor.""" diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 6b3b84ba349eaa..b8c66c5cc35c08 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -39,14 +39,14 @@ } -@dataclass +@dataclass(frozen=True) class TuyaClimateSensorDescriptionMixin: """Define an entity description mixin for climate entities.""" switch_only_hvac_mode: HVACMode -@dataclass +@dataclass(frozen=True) class TuyaClimateEntityDescription( ClimateEntityDescription, TuyaClimateSensorDescriptionMixin ): diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index bf2c54a6158b45..f933ac8451986e 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -7,16 +7,14 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from .const import ( CONF_ACCESS_ID, CONF_ACCESS_SECRET, CONF_APP_TYPE, CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, CONF_ENDPOINT, - CONF_PASSWORD, - CONF_USERNAME, DOMAIN, LOGGER, SMARTLIFE_APP, diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index acf9f8bbd2cef7..4cdca8f39048af 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -36,9 +36,6 @@ CONF_ENDPOINT = "endpoint" CONF_ACCESS_ID = "access_id" CONF_ACCESS_SECRET = "access_secret" -CONF_USERNAME = "username" -CONF_PASSWORD = "password" -CONF_COUNTRY_CODE = "country_code" CONF_APP_TYPE = "tuya_app_type" TUYA_DISCOVERY_NEW = "tuya_discovery_new" @@ -338,6 +335,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 +360,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/cover.py b/homeassistant/components/tuya/cover.py index da9f7d29eb2c5f..46bd0721ccb516 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -24,7 +24,7 @@ from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType -@dataclass +@dataclass(frozen=True) class TuyaCoverEntityDescription(CoverEntityDescription): """Describe an Tuya cover entity.""" diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 454416970eaa5d..adac97174b9117 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -9,20 +9,14 @@ from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.util import dt as dt_util from . import HomeAssistantTuyaData -from .const import ( - CONF_APP_TYPE, - CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, - CONF_ENDPOINT, - DOMAIN, - DPCode, -) +from .const import CONF_APP_TYPE, CONF_AUTH_TYPE, CONF_ENDPOINT, DOMAIN, DPCode async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 6d09ba4314cc87..a8008ced953178 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,7 +21,7 @@ from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType -@dataclass +@dataclass(frozen=True) class TuyaHumidifierEntityDescription(HumidifierEntityDescription): """Describe an Tuya (de)humidifier entity.""" diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index b4396f617cda39..8e98e8d6a410d3 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -49,7 +49,7 @@ class ColorTypeData: ) -@dataclass +@dataclass(frozen=True) class TuyaLightEntityDescription(LightEntityDescription): """Describe an Tuya light entity.""" diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 3cc8c72f555566..bc44ddf479cc2f 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -75,6 +75,16 @@ 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 9f055a6262ef73..62b59cb8ed936e 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -38,7 +38,7 @@ ) -@dataclass +@dataclass(frozen=True) class TuyaSensorEntityDescription(SensorEntityDescription): """Describes Tuya sensor entity.""" @@ -517,6 +517,18 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *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 @@ class TuyaSensorEntityDescription(SensorEntityDescription): 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 9c8074195519b1..e9b13e10a957be 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 a48d797555c4f3..ba304b4069e56e 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -430,6 +430,14 @@ 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 6cb98444be6270..aef70aa6a10159 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 fba10a269f7cce..32b4de47de47f6 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -21,20 +21,13 @@ from .entity import TwenteMilieuEntity -@dataclass -class TwenteMilieuSensorDescriptionMixin: - """Define an entity description mixin.""" +@dataclass(frozen=True, 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/__init__.py b/homeassistant/components/twinkly/__init__.py index 897bfaf4e20e71..d57a56f489b92e 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -6,12 +6,12 @@ from ttls.client import Twinkly from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SW_VERSION, Platform +from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ATTR_VERSION, CONF_HOST, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN +from .const import ATTR_VERSION, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN PLATFORMS = [Platform.LIGHT] diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index eab44dba59105f..e37e0fd6170202 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -11,10 +11,10 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_ID, CONF_NAME, DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN +from .const import DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twinkly/const.py b/homeassistant/components/twinkly/const.py index 2158e4aae07c5b..f33024ed156b6c 100644 --- a/homeassistant/components/twinkly/const.py +++ b/homeassistant/components/twinkly/const.py @@ -2,11 +2,6 @@ DOMAIN = "twinkly" -# Keys of the config entry -CONF_ID = "id" -CONF_HOST = "host" -CONF_NAME = "name" - # Strongly named HA attributes keys ATTR_HOST = "host" ATTR_VERSION = "version" diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 6d0b31b06ed388..c43019360886c6 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -19,16 +19,19 @@ LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SW_VERSION, CONF_MODEL +from homeassistant.const import ( + ATTR_SW_VERSION, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, DATA_CLIENT, DATA_DEVICE_INFO, DEV_LED_PROFILE, diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index 9b4c8ebd778606..88bc67abbbd3a6 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 3bda5284c0f1dd..f4128a15adc67a 100644 --- a/homeassistant/components/twitch/strings.json +++ b/homeassistant/components/twitch/strings.json @@ -13,8 +13,8 @@ "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]" + "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 7471675123aba7..c77a1f01447f9c 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -11,8 +11,14 @@ 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,14 +48,23 @@ async def async_restart_device_control_fn( await api.request(DeviceRestartRequest.create(obj_id)) -@dataclass +@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(frozen=True) class UnifiButtonEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" control_fn: Callable[[aiounifi.Controller, str], Coroutine[Any, Any, None]] -@dataclass +@dataclass(frozen=True) class UnifiButtonEntityDescription( ButtonEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], @@ -77,6 +92,24 @@ class 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 a678517eca9e51..e1867b2df2e5d8 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -8,6 +8,7 @@ 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 @@ async def async_step_configure_entity_sources( 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 @@ async def async_step_configure_entity_sources( 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/const.py b/homeassistant/components/unifi/const.py index c78313f66e2812..2b16895a9a85c4 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -2,6 +2,8 @@ import logging +from aiounifi.models.device import DeviceState + from homeassistant.const import Platform LOGGER = logging.getLogger(__package__) @@ -46,3 +48,19 @@ BLOCK_SWITCH = "block" DPI_SWITCH = "dpi" OUTLET_SWITCH = "outlet" + +DEVICE_STATES = { + DeviceState.DISCONNECTED: "Disconnected", + DeviceState.CONNECTED: "Connected", + DeviceState.PENDING: "Pending", + DeviceState.FIRMWARE_MISMATCH: "Firmware Mismatch", + DeviceState.UPGRADING: "Upgrading", + DeviceState.PROVISIONING: "Provisioning", + DeviceState.HEARTBEAT_MISSED: "Heartbeat Missed", + DeviceState.ADOPTING: "Adopting", + DeviceState.DELETING: "Deleting", + DeviceState.INFORM_ERROR: "Inform Error", + DeviceState.ADOPTION_FALIED: "Adoption Failed", + DeviceState.ISOLATED: "Isolated", + DeviceState.UNKNOWN: "Unknown", +} diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index b89e64f285f80b..a941e836ae2382 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -5,7 +5,7 @@ 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 @@ -260,8 +260,8 @@ async def initialize(self) -> None: for entry in async_entries_for_config_entry( entity_registry, self.config_entry.entry_id ): - if entry.domain == Platform.DEVICE_TRACKER: - macs.append(entry.unique_id.split("-", 1)[0]) + if entry.domain == Platform.DEVICE_TRACKER and "-" in entry.unique_id: + macs.append(entry.unique_id.split("-", 1)[1]) for mac in self.option_supported_clients + self.option_block_clients + macs: if mac not in self.api.clients and mac in self.api.clients_all: @@ -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 5c9694c669ce7e..88667d8e81160a 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -17,14 +17,15 @@ 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, @@ -136,7 +137,7 @@ def async_device_heartbeat_timedelta_fn( return timedelta(seconds=device.next_interval + 60) -@dataclass +@dataclass(frozen=True) class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" @@ -146,7 +147,7 @@ class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): hostname_fn: Callable[[aiounifi.Controller, str], str | None] -@dataclass +@dataclass(frozen=True) class UnifiTrackerEntityDescription( UnifiEntityDescription[HandlerT, ApiItemT], UnifiEntityTrackerDescriptionMixin[HandlerT, ApiItemT], @@ -175,7 +176,7 @@ class 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 @@ class 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/entity.py b/homeassistant/components/unifi/entity.py index 28a7b557b16edd..08dda12c11da42 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -93,7 +93,7 @@ def async_client_device_info_fn(controller: UniFiController, obj_id: str) -> Dev ) -@dataclass +@dataclass(frozen=True) class UnifiDescription(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -110,7 +110,7 @@ class UnifiDescription(Generic[HandlerT, ApiItemT]): unique_id_fn: Callable[[UniFiController, str], str] -@dataclass +@dataclass(frozen=True) class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, ApiItemT]): """UniFi Entity Description.""" diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 2318702f0d15b0..a4fb8d5eb33aad 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -36,7 +36,7 @@ def async_wlan_qr_code_image_fn(controller: UniFiController, wlan: Wlan) -> byte return controller.api.wlans.generate_wlan_qr_code(wlan) -@dataclass +@dataclass(frozen=True) class UnifiImageEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -44,7 +44,7 @@ class UnifiImageEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): value_fn: Callable[[ApiItemT], str | None] -@dataclass +@dataclass(frozen=True) class UnifiImageEntityDescription( ImageEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index ed8649896ddb8b..4a43a65d5bbba1 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==68"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 3d0ffa1896e9bc..c7b851a8fbb95b 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -36,6 +36,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util +from .const import DEVICE_STATES from .controller import UniFiController from .entity import ( HandlerT, @@ -131,14 +132,20 @@ def async_device_outlet_supported_fn(controller: UniFiController, obj_id: str) - return controller.api.devices[obj_id].outlet_ac_power_budget is not None -@dataclass +@dataclass(frozen=True) class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" value_fn: Callable[[UniFiController, ApiItemT], datetime | float | str | None] -@dataclass +@callback +def async_device_state_value_fn(controller: UniFiController, device: Device) -> str: + """Retrieve the state of the device.""" + return DEVICE_STATES[device.state] + + +@dataclass(frozen=True) class UnifiSensorEntityDescription( SensorEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], @@ -151,6 +158,7 @@ class 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 +179,7 @@ class 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 +240,7 @@ class 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, @@ -340,6 +350,25 @@ class UnifiSensorEntityDescription( unique_id_fn=lambda controller, obj_id: f"device_temperature-{obj_id}", value_fn=lambda ctrlr, device: device.general_temperature, ), + UnifiSensorEntityDescription[Devices, Device]( + key="Device State", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "State", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"device_state-{obj_id}", + value_fn=async_device_state_value_fn, + options=list(DEVICE_STATES.values()), + ), ) diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 9c609ca8c07de5..ba426c2f08a358 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 41c1f55a22a316..371676f4786c17 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -42,9 +42,10 @@ 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, @@ -179,7 +180,7 @@ async def async_wlan_control_fn( await controller.api.request(WlanEnableRequest.create(obj_id, target)) -@dataclass +@dataclass(frozen=True) class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -187,7 +188,7 @@ class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): is_on_fn: Callable[[UniFiController, ApiItemT], bool] -@dataclass +@dataclass(frozen=True) class UnifiSwitchEntityDescription( SwitchEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], @@ -256,7 +257,7 @@ class 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 @@ class 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 @@ class 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/unifi/update.py b/homeassistant/components/unifi/update.py index 65b26736cf1d37..a0d2da328a2dcd 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -40,7 +40,7 @@ async def async_device_control_fn(api: aiounifi.Controller, obj_id: str) -> None await api.request(DeviceUpgradeRequest.create(obj_id)) -@dataclass +@dataclass(frozen=True) class UnifiUpdateEntityDescriptionMixin(Generic[_HandlerT, _DataT]): """Validate and load entities from different UniFi handlers.""" @@ -48,7 +48,7 @@ class UnifiUpdateEntityDescriptionMixin(Generic[_HandlerT, _DataT]): state_fn: Callable[[aiounifi.Controller, _DataT], bool] -@dataclass +@dataclass(frozen=True) class UnifiUpdateEntityDescription( UpdateEntityDescription, UnifiEntityDescription[_HandlerT, _DataT], diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index 13ebd0e33e5748..77ce5d80cf904f 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -1,10 +1,10 @@ """Support for Unifi AP direct access.""" from __future__ import annotations -import json import logging +from typing import Any -from pexpect import exceptions, pxssh +from unifi_ap import UniFiAP, UniFiAPConnectionException, UniFiAPDataException import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -20,9 +20,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_SSH_PORT = 22 -UNIFI_COMMAND = 'mca-dump | tr -d "\n"' -UNIFI_SSID_TABLE = "vap_table" -UNIFI_CLIENT_TABLE = "sta_table" PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { @@ -37,104 +34,43 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> UnifiDeviceScanner | None: """Validate the configuration and return a Unifi direct scanner.""" scanner = UnifiDeviceScanner(config[DOMAIN]) - if not scanner.connected: - return None - return scanner + return scanner if scanner.update_clients() else None class UnifiDeviceScanner(DeviceScanner): """Class which queries Unifi wireless access point.""" - def __init__(self, config): + def __init__(self, config: ConfigType) -> None: """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - self.port = config[CONF_PORT] - self.ssh = None - self.connected = False - self.last_results = {} - self._connect() + self.clients: dict[str, dict[str, Any]] = {} + self.ap = UniFiAP( + target=config[CONF_HOST], + username=config[CONF_USERNAME], + password=config[CONF_PASSWORD], + port=config[CONF_PORT], + ) - def scan_devices(self): + def scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" - result = _response_to_json(self._get_update()) - if result: - self.last_results = result - return self.last_results.keys() + self.update_clients() + return list(self.clients) - def get_device_name(self, device): + def get_device_name(self, device: str) -> str | None: """Return the name of the given device or None if we don't know.""" - hostname = next( - ( - value.get("hostname") - for key, value in self.last_results.items() - if key.upper() == device.upper() - ), - None, - ) - if hostname is not None: - hostname = str(hostname) - return hostname - - def _connect(self): - """Connect to the Unifi AP SSH server.""" - - self.ssh = pxssh.pxssh(options={"HostKeyAlgorithms": "ssh-rsa"}) - try: - self.ssh.login( - self.host, self.username, password=self.password, port=self.port - ) - self.connected = True - except exceptions.EOF: - _LOGGER.error("Connection refused. SSH enabled?") - self._disconnect() - - def _disconnect(self): - """Disconnect the current SSH connection.""" - try: - self.ssh.logout() - except Exception: # pylint: disable=broad-except - pass - finally: - self.ssh = None - - self.connected = False + client_info = self.clients.get(device) + if client_info: + return client_info.get("hostname") + return None - def _get_update(self): + def update_clients(self) -> bool: + """Update the client info from AP.""" try: - if not self.connected: - self._connect() - # If we still aren't connected at this point - # don't try to send anything to the AP. - if not self.connected: - return None - self.ssh.sendline(UNIFI_COMMAND) - self.ssh.prompt() - return self.ssh.before - except pxssh.ExceptionPxssh as err: - _LOGGER.error("Unexpected SSH error: %s", str(err)) - self._disconnect() - return None - except (AssertionError, exceptions.EOF) as err: - _LOGGER.error("Connection to AP unavailable: %s", str(err)) - self._disconnect() - return None - - -def _response_to_json(response): - try: - json_response = json.loads(str(response)[31:-1].replace("\\", "")) - _LOGGER.debug(str(json_response)) - ssid_table = json_response.get(UNIFI_SSID_TABLE) - active_clients = {} - - for ssid in ssid_table: - client_table = ssid.get(UNIFI_CLIENT_TABLE) - for client in client_table: - active_clients[client.get("mac")] = client - - return active_clients - except (ValueError, TypeError): - _LOGGER.error("Failed to decode response from AP") - return {} + self.clients = self.ap.get_clients() + except UniFiAPConnectionException: + _LOGGER.error("Failed to connect to accesspoint") + return False + except UniFiAPDataException: + _LOGGER.error("Failed to get proper response from accesspoint") + return False + + return True diff --git a/homeassistant/components/unifi_direct/manifest.json b/homeassistant/components/unifi_direct/manifest.json index 68a1396727fc68..8ca8ef27bb2dbe 100644 --- a/homeassistant/components/unifi_direct/manifest.json +++ b/homeassistant/components/unifi_direct/manifest.json @@ -1,9 +1,9 @@ { "domain": "unifi_direct", "name": "UniFi AP", - "codeowners": [], + "codeowners": ["@tofuSCHNITZEL"], "documentation": "https://www.home-assistant.io/integrations/unifi_direct", "iot_class": "local_polling", - "loggers": ["pexpect", "ptyprocess"], - "requirements": ["pexpect==4.6.0"] + "loggers": ["unifi_ap"], + "requirements": ["unifi_ap==0.0.1"] } diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 8f8bcab8edeba4..1104ecb98e1541 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -1,8 +1,7 @@ """Component providing binary sensors for UniFi Protect.""" from __future__ import annotations -from copy import copy -from dataclasses import dataclass +import dataclasses import logging from pyunifiprotect.data import ( @@ -43,14 +42,14 @@ _KEY_DOOR = "door" -@dataclass +@dataclasses.dataclass(frozen=True) class ProtectBinaryEntityDescription( ProtectRequiredKeysMixin, BinarySensorEntityDescription ): """Describes UniFi Protect Binary Sensor entity.""" -@dataclass +@dataclasses.dataclass(frozen=True) class ProtectBinaryEventEntityDescription( ProtectEventMixin, BinarySensorEntityDescription ): @@ -561,9 +560,11 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: self._attr_is_on = entity_description.get_ufp_value(updated_device) # UP Sense can be any of the 3 contact sensor device classes if entity_description.key == _KEY_DOOR and isinstance(updated_device, Sensor): - entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( + self._attr_device_class = MOUNT_DEVICE_CLASS_MAP.get( updated_device.mount_type, BinarySensorDeviceClass.DOOR ) + else: + self._attr_device_class = self.entity_description.device_class class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): @@ -584,9 +585,11 @@ def __init__( # backwards compat with old unique IDs index = self._disk.slot - 1 - description = copy(description) - description.key = f"{description.key}_{index}" - description.name = f"{disk.type} {disk.slot}" + description = dataclasses.replace( + description, + key=f"{description.key}_{index}", + name=f"{disk.type} {disk.slot}", + ) super().__init__(data, device, description) @callback @@ -640,4 +643,15 @@ def _async_updated_event(self, device: ProtectModelWithId) -> None: or self._attr_extra_state_attributes != previous_extra_state_attributes or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_is_on, + previous_available, + previous_extra_state_attributes, + self._attr_is_on, + self._attr_available, + self._attr_extra_state_attributes, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index bc93c1568662b7..b69fbb95970140 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ProtectButtonEntityDescription( ProtectSetableKeysMixin[T], ButtonEntityDescription ): @@ -206,4 +206,11 @@ def _async_updated_event(self, device: ProtectModelWithId) -> None: previous_available = self._attr_available self._async_update_device_from_protect(device) if self._attr_available != previous_available: + _LOGGER.debug( + "Updating state [%s (%s)] %s -> %s", + device.name, + device.mac, + previous_available, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 73d05f1be1d77f..8b8ec80c5ba515 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -228,6 +228,8 @@ def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None: # trigger updates for camera that the event references elif isinstance(obj, Event): # type: ignore[unreachable] + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("event WS msg: %s", obj.dict()) if obj.type in SMART_EVENTS: if obj.camera is not None: if obj.end is None: diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index ee6f6d055489a4..2fbf8f31071368 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.21.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.5", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index df5ea40d4a912e..b2376277e6f853 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -133,6 +133,17 @@ def _async_updated_event(self, device: ProtectModelWithId) -> None: or self._attr_volume_level != previous_volume_level or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_state, + previous_available, + previous_volume_level, + self._attr_state, + self._attr_available, + self._attr_volume_level, + ) self.async_write_ha_state() async def async_set_volume_level(self, volume: float) -> None: diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index c250a021340907..7f5612a72a8adb 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -36,7 +36,7 @@ class PermRequired(int, Enum): DELETE = 3 -@dataclass +@dataclass(frozen=True) class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): """Mixin for required keys.""" @@ -52,9 +52,11 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): def __post_init__(self) -> None: """Pre-convert strings to tuples for faster get_nested_attr.""" - self.ufp_required_field = split_tuple(self.ufp_required_field) - self.ufp_value = split_tuple(self.ufp_value) - self.ufp_enabled = split_tuple(self.ufp_enabled) + object.__setattr__( + self, "ufp_required_field", split_tuple(self.ufp_required_field) + ) + object.__setattr__(self, "ufp_value", split_tuple(self.ufp_value)) + object.__setattr__(self, "ufp_enabled", split_tuple(self.ufp_enabled)) def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device.""" @@ -99,7 +101,7 @@ def has_required(self, obj: T) -> bool: return bool(get_nested_attr(obj, ufp_required_field)) -@dataclass +@dataclass(frozen=True) class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Mixin for events.""" @@ -125,7 +127,7 @@ def get_is_on(self, event: Event | None) -> bool: return value -@dataclass +@dataclass(frozen=True) class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]): """Mixin for settable values.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 08bc9f385279bd..c02753a9401626 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from datetime import timedelta +import logging from pyunifiprotect.data import ( Camera, @@ -25,8 +26,10 @@ from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd +_LOGGER = logging.getLogger(__name__) -@dataclass + +@dataclass(frozen=True) class NumberKeysMixin: """Mixin for required keys.""" @@ -35,7 +38,7 @@ class NumberKeysMixin: ufp_step: int | float -@dataclass +@dataclass(frozen=True) class ProtectNumberEntityDescription( ProtectSetableKeysMixin[T], NumberEntityDescription, NumberKeysMixin ): @@ -285,4 +288,13 @@ def _async_updated_event(self, device: ProtectModelWithId) -> None: self._attr_native_value != previous_value or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_value, + previous_available, + self._attr_native_value, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7605be17fc93ea..dfc3be2d4a1106 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -92,7 +92,7 @@ DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message" -@dataclass +@dataclass(frozen=True) class ProtectSelectEntityDescription( ProtectSetableKeysMixin[T], SelectEntityDescription ): @@ -420,4 +420,15 @@ def _async_updated_event(self, device: ProtectModelWithId) -> None: or self._attr_options != previous_options or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_option, + previous_available, + previous_options, + self._attr_current_option, + self._attr_available, + self._attr_options, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 756da49eb4d054..3e2bd6ee858035 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -54,7 +54,7 @@ OBJECT_TYPE_NONE = "none" -@dataclass +@dataclass(frozen=True) class ProtectSensorEntityDescription( ProtectRequiredKeysMixin[T], SensorEntityDescription ): @@ -71,7 +71,7 @@ def get_ufp_value(self, obj: T) -> Any: return value -@dataclass +@dataclass(frozen=True) class ProtectSensorEventEntityDescription( ProtectEventMixin[T], SensorEntityDescription ): @@ -730,6 +730,15 @@ def _async_updated_event(self, device: ProtectModelWithId) -> None: self._attr_native_value != previous_value or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_value, + previous_available, + self._attr_native_value, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 73ac6e08c1756c..a345a504c4267d 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/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f1e6185b01024d..d8a3fc1c5bc195 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +import logging from typing import Any from pyunifiprotect.data import ( @@ -27,11 +28,12 @@ from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd +_LOGGER = logging.getLogger(__name__) ATTR_PREV_MIC = "prev_mic_level" ATTR_PREV_RECORD = "prev_record_mode" -@dataclass +@dataclass(frozen=True) class ProtectSwitchEntityDescription( ProtectSetableKeysMixin[T], SwitchEntityDescription ): @@ -209,6 +211,16 @@ async def _set_highfps(obj: Camera, value: bool) -> None: ufp_set_method="set_smoke_detection", ufp_perm=PermRequired.WRITE, ), + ProtectSwitchEntityDescription( + key="color_night_vision", + name="Color Night Vision", + icon="mdi:light-flood-down", + entity_category=EntityCategory.CONFIG, + ufp_required_field="has_color_night_vision", + ufp_value="isp_settings.is_color_night_vision_enabled", + ufp_set_method="set_color_night_vision", + ufp_perm=PermRequired.WRITE, + ), ) PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera]( @@ -448,6 +460,15 @@ def _async_updated_event(self, device: ProtectModelWithId) -> None: self._attr_is_on != previous_is_on or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_is_on, + previous_available, + self._attr_is_on, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index c39f7895231796..de777121ff5f5f 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -24,7 +24,7 @@ from .utils import async_dispatch_id as _ufpd -@dataclass +@dataclass(frozen=True) class ProtectTextEntityDescription(ProtectSetableKeysMixin[T], TextEntityDescription): """Describes UniFi Protect Text entity.""" diff --git a/homeassistant/components/universal/manifest.json b/homeassistant/components/universal/manifest.json index 587d2c7aad2baa..4cf52892aaff63 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/__init__.py b/homeassistant/components/update/__init__.py index c9496ce8f7bf77..40431332aafedf 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -1,12 +1,11 @@ """Component to allow for providing device or service updates.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum from functools import lru_cache import logging -from typing import Any, Final, final +from typing import TYPE_CHECKING, Any, Final, final from awesomeversion import AwesomeVersion, AwesomeVersionCompareException import voluptuous as vol @@ -21,7 +20,7 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity import ABCCachedProperties, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -43,6 +42,11 @@ UpdateEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(minutes=15) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" @@ -136,7 +140,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If version is specified, but not supported by the entity. if ( version is not None - and not entity.supported_features & UpdateEntityFeature.SPECIFIC_VERSION + and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features ): raise HomeAssistantError( f"Installing a specific version is not supported for {entity.entity_id}" @@ -145,7 +149,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If backup is requested, but not supported by the entity. if ( backup := service_call.data[ATTR_BACKUP] - ) and not entity.supported_features & UpdateEntityFeature.BACKUP: + ) and UpdateEntityFeature.BACKUP not in entity.supported_features: raise HomeAssistantError(f"Backup is not supported for {entity.entity_id}") # Update is already in progress. @@ -175,8 +179,7 @@ async def async_clear_skipped(entity: UpdateEntity, service_call: ServiceCall) - await entity.async_clear_skipped() -@dataclass -class UpdateEntityDescription(EntityDescription): +class UpdateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes update entities.""" device_class: UpdateDeviceClass | None = None @@ -189,7 +192,24 @@ def _version_is_newer(latest_version: str, installed_version: str) -> bool: return AwesomeVersion(latest_version) > installed_version -class UpdateEntity(RestoreEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "auto_update", + "installed_version", + "device_class", + "in_progress", + "latest_version", + "release_summary", + "release_url", + "supported_features", + "title", +} + + +class UpdateEntity( + RestoreEntity, + metaclass=ABCCachedProperties, + cached_properties=CACHED_PROPERTIES_WITH_ATTR_, +): """Representation of an update entity.""" _entity_component_unrecorded_attributes = frozenset( @@ -210,12 +230,12 @@ class UpdateEntity(RestoreEntity): __skipped_version: str | None = None __in_progress: bool = False - @property + @cached_property def auto_update(self) -> bool: """Indicate if the device or service has auto update enabled.""" return self._attr_auto_update - @property + @cached_property def installed_version(self) -> str | None: """Version installed and in use.""" return self._attr_installed_version @@ -227,7 +247,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @property + @cached_property def device_class(self) -> UpdateDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -243,7 +263,7 @@ def entity_category(self) -> EntityCategory | None: return self._attr_entity_category if hasattr(self, "entity_description"): return self.entity_description.entity_category - if self.supported_features & UpdateEntityFeature.INSTALL: + if UpdateEntityFeature.INSTALL in self.supported_features_compat: return EntityCategory.CONFIG return EntityCategory.DIAGNOSTIC @@ -258,7 +278,7 @@ def entity_picture(self) -> str | None: f"https://brands.home-assistant.io/_/{self.platform.platform_name}/icon.png" ) - @property + @cached_property def in_progress(self) -> bool | int | None: """Update installation progress. @@ -269,12 +289,12 @@ def in_progress(self) -> bool | int | None: """ return self._attr_in_progress - @property + @cached_property def latest_version(self) -> str | None: """Latest version available for install.""" return self._attr_latest_version - @property + @cached_property def release_summary(self) -> str | None: """Summary of the release notes or changelog. @@ -283,17 +303,17 @@ def release_summary(self) -> str | None: """ return self._attr_release_summary - @property + @cached_property def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" return self._attr_release_url - @property + @cached_property def supported_features(self) -> UpdateEntityFeature: """Flag supported features.""" return self._attr_supported_features - @property + @cached_property def title(self) -> str | None: """Title of the software. @@ -302,6 +322,19 @@ def title(self) -> str | None: """ return self._attr_title + @property + def supported_features_compat(self) -> UpdateEntityFeature: + """Return the supported features as UpdateEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = UpdateEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @final async def async_skip(self) -> None: """Skip the current offered version to update.""" @@ -388,7 +421,7 @@ def state_attributes(self) -> dict[str, Any] | None: # If entity supports progress, return the in_progress value. # Otherwise, we use the internal progress value. - if self.supported_features & UpdateEntityFeature.PROGRESS: + if UpdateEntityFeature.PROGRESS in self.supported_features_compat: in_progress = self.in_progress else: in_progress = self.__in_progress @@ -424,7 +457,7 @@ async def async_install_with_progress( Handles setting the in_progress state in case the entity doesn't support it natively. """ - if not self.supported_features & UpdateEntityFeature.PROGRESS: + if UpdateEntityFeature.PROGRESS not in self.supported_features_compat: self.__in_progress = True self.async_write_ha_state() @@ -470,7 +503,7 @@ async def websocket_release_notes( ) return - if not entity.supported_features & UpdateEntityFeature.RELEASE_NOTES: + if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features_compat: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_SUPPORTED, diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 326ff5d7651e93..6af9d85bc87cd3 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -26,7 +26,7 @@ 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 device_discovered( 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 device_discovered( # 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/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index 0ab8962077b9a5..676b9588ddb0fe 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -18,7 +18,7 @@ from .entity import UpnpEntity, UpnpEntityDescription -@dataclass +@dataclass(frozen=True) class UpnpBinarySensorEntityDescription( UpnpEntityDescription, BinarySensorEntityDescription ): diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 35d665363750a1..b32273a3f2465d 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from typing import Any, cast +from urllib.parse import urlparse import voluptuous as vol @@ -25,7 +26,7 @@ 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 @@ async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowRes # 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 @@ async def async_step_ignore(self, user_input: dict[str, Any]) -> FlowResult: 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 @@ async def _async_create_entry_from_discovery( 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 b62edbf9bc2216..2f52a5d008f593 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -33,6 +33,22 @@ ) +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 @@ def unique_id(self) -> str: @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 @@ -142,7 +157,7 @@ async def async_get_data(self) -> dict[str, str | datetime | int | float | None] _LOGGER.debug("Getting data for device: %s", self) igd_state = await self._igd_device.async_get_traffic_and_status_data() status_info = igd_state.status_info - if status_info is not None and not isinstance(status_info, Exception): + if status_info is not None and not isinstance(status_info, BaseException): wan_status = status_info.connection_status router_uptime = status_info.uptime else: @@ -150,7 +165,7 @@ async def async_get_data(self) -> dict[str, str | datetime | int | float | None] router_uptime = None def get_value(value: Any) -> Any: - if value is None or isinstance(value, Exception): + if value is None or isinstance(value, BaseException): return None return value diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py index e53d89018fb52e..504602372f7a41 100644 --- a/homeassistant/components/upnp/entity.py +++ b/homeassistant/components/upnp/entity.py @@ -10,7 +10,7 @@ from .coordinator import UpnpDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class UpnpEntityDescription(EntityDescription): """UPnP entity description.""" @@ -19,7 +19,7 @@ class UpnpEntityDescription(EntityDescription): def __post_init__(self): """Post initialize.""" - self.value_key = self.value_key or self.key + object.__setattr__(self, "value_key", self.value_key or self.key) class UpnpEntity(CoordinatorEntity[UpnpDataUpdateCoordinator]): diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 25f83e0dbf513b..4b6badb0d3c46f 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.36.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.38.0", "getmac==0.9.4"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 46d748f6939e5b..e493118f58e09f 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -41,7 +41,7 @@ from .entity import UpnpEntity, UpnpEntityDescription -@dataclass +@dataclass(frozen=True) class UpnpSensorEntityDescription(UpnpEntityDescription, SensorEntityDescription): """A class that describes a sensor UPnP entities.""" diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 97978a9ebc2ff9..3cf615caa3c45a 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -11,7 +11,12 @@ from .const import DOMAIN from .coordinator import V2CUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, + Platform.NUMBER, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py new file mode 100644 index 00000000000000..b30c632174aee0 --- /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(frozen=True) +class V2CRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Trydan], bool] + + +@dataclass(frozen=True) +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/number.py b/homeassistant/components/v2c/number.py index 0f2551818a2783..dd20b0de7870c9 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -24,7 +24,7 @@ MAX_INTENSITY = 32 -@dataclass +@dataclass(frozen=True) class V2CSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -32,7 +32,7 @@ class V2CSettingsRequiredKeysMixin: update_fn: Callable[[Trydan, int], Coroutine[Any, Any, None]] -@dataclass +@dataclass(frozen=True) class V2CSettingsNumberEntityDescription( NumberEntityDescription, V2CSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 0c860943922b1f..0aa727fa4080dc 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -25,14 +25,14 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class V2CRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[TrydanData], float] -@dataclass +@dataclass(frozen=True) class V2CSensorEntityDescription(SensorEntityDescription, V2CRequiredKeysMixin): """Describes an EVSE Power sensor entity.""" @@ -41,6 +41,7 @@ class V2CSensorEntityDescription(SensorEntityDescription, V2CRequiredKeysMixin): V2CSensorEntityDescription( key="charge_power", translation_key="charge_power", + icon="mdi:ev-station", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, @@ -49,6 +50,7 @@ class V2CSensorEntityDescription(SensorEntityDescription, V2CRequiredKeysMixin): V2CSensorEntityDescription( key="charge_energy", translation_key="charge_energy", + icon="mdi:ev-station", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, @@ -57,6 +59,7 @@ class V2CSensorEntityDescription(SensorEntityDescription, V2CRequiredKeysMixin): V2CSensorEntityDescription( key="charge_time", translation_key="charge_time", + icon="mdi:timer", native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.DURATION, @@ -65,6 +68,7 @@ class V2CSensorEntityDescription(SensorEntityDescription, V2CRequiredKeysMixin): V2CSensorEntityDescription( key="house_power", translation_key="house_power", + icon="mdi:home-lightning-bolt", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, @@ -73,6 +77,7 @@ class V2CSensorEntityDescription(SensorEntityDescription, V2CRequiredKeysMixin): V2CSensorEntityDescription( key="fv_power", translation_key="fv_power", + icon="mdi:solar-power-variant", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, @@ -99,7 +104,6 @@ class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): """Defines a base v2c sensor entity.""" entity_description: V2CSensorEntityDescription - _attr_icon = "mdi:ev-station" def __init__( self, diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index 749cfb9979e897..a60b61831fde6b 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your V2C Trydan EVSE." } } }, @@ -13,6 +16,17 @@ } }, "entity": { + "binary_sensor": { + "connected": { + "name": "Connected" + }, + "charging": { + "name": "Charging" + }, + "ready": { + "name": "Ready" + } + }, "number": { "intensity": { "name": "Intensity" @@ -38,6 +52,18 @@ "switch": { "paused": { "name": "Pause session" + }, + "locked": { + "name": "Lock EVSE" + }, + "timer": { + "name": "Charge point timer" + }, + "dynamic": { + "name": "Dynamic intensity modulation" + }, + "pause_dynamic": { + "name": "Pause dynamic control modulation" } } } diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index 4e56e72dcbf325..a8b4728c66dbd2 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -7,7 +7,13 @@ from typing import Any from pytrydan import Trydan, TrydanData -from pytrydan.models.trydan import PauseState +from pytrydan.models.trydan import ( + ChargePointTimerState, + DynamicState, + LockState, + PauseDynamicState, + PauseState, +) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -21,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class V2CRequiredKeysMixin: """Mixin for required keys.""" @@ -30,7 +36,7 @@ class V2CRequiredKeysMixin: turn_off_fn: Callable[[Trydan], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class V2CSwitchEntityDescription(SwitchEntityDescription, V2CRequiredKeysMixin): """Describes a V2C EVSE switch entity.""" @@ -44,6 +50,39 @@ class V2CSwitchEntityDescription(SwitchEntityDescription, V2CRequiredKeysMixin): turn_on_fn=lambda evse: evse.pause(), turn_off_fn=lambda evse: evse.resume(), ), + V2CSwitchEntityDescription( + key="locked", + translation_key="locked", + icon="mdi:lock", + value_fn=lambda evse_data: evse_data.locked == LockState.ENABLED, + turn_on_fn=lambda evse: evse.lock(), + turn_off_fn=lambda evse: evse.unlock(), + ), + V2CSwitchEntityDescription( + key="timer", + translation_key="timer", + icon="mdi:timer", + value_fn=lambda evse_data: evse_data.timer == ChargePointTimerState.TIMER_ON, + turn_on_fn=lambda evse: evse.timer(), + turn_off_fn=lambda evse: evse.timer_disable(), + ), + V2CSwitchEntityDescription( + key="dynamic", + translation_key="dynamic", + icon="mdi:gauge", + value_fn=lambda evse_data: evse_data.dynamic == DynamicState.ENABLED, + turn_on_fn=lambda evse: evse.dynamic(), + turn_off_fn=lambda evse: evse.dynamic_disable(), + ), + V2CSwitchEntityDescription( + key="pause_dynamic", + translation_key="pause_dynamic", + icon="mdi:pause", + value_fn=lambda evse_data: evse_data.pause_dynamic + == PauseDynamicState.NOT_MODULATING, + turn_on_fn=lambda evse: evse.pause_dynamic(), + turn_off_fn=lambda evse: evse.resume_dynamic(), + ), ) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index c0680913df6748..9a10da23824259 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -3,12 +3,11 @@ import asyncio from collections.abc import Mapping -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag from functools import partial import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -46,6 +45,11 @@ bind_hass, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) DOMAIN = "vacuum" @@ -226,7 +230,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class _BaseVacuum(Entity): +BASE_CACHED_PROPERTIES_WITH_ATTR_ = { + "supported_features", + "battery_level", + "battery_icon", + "fan_speed", + "fan_speed_list", +} + + +class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): """Representation of a base vacuum. Contains common properties and functions for all vacuum devices. @@ -240,27 +253,40 @@ class _BaseVacuum(Entity): _attr_fan_speed_list: list[str] _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) - @property + @cached_property def supported_features(self) -> VacuumEntityFeature: """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features @property + def supported_features_compat(self) -> VacuumEntityFeature: + """Return the supported features as VacuumEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = VacuumEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + + @cached_property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" return self._attr_battery_level - @property + @cached_property def battery_icon(self) -> str: """Return the battery icon for the vacuum cleaner.""" return self._attr_battery_icon - @property + @cached_property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" return self._attr_fan_speed - @property + @cached_property def fan_speed_list(self) -> list[str]: """Get the list of available fan speed steps of the vacuum cleaner.""" return self._attr_fan_speed_list @@ -268,7 +294,7 @@ def fan_speed_list(self) -> list[str]: @property def capability_attributes(self) -> Mapping[str, Any] | None: """Return capability attributes.""" - if self.supported_features & VacuumEntityFeature.FAN_SPEED: + if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -276,12 +302,13 @@ def capability_attributes(self) -> Mapping[str, Any] | None: def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} + supported_features = self.supported_features_compat - if self.supported_features & VacuumEntityFeature.BATTERY: + if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level data[ATTR_BATTERY_ICON] = self.battery_icon - if self.supported_features & VacuumEntityFeature.FAN_SPEED: + if VacuumEntityFeature.FAN_SPEED in supported_features: data[ATTR_FAN_SPEED] = self.fan_speed return data @@ -367,12 +394,18 @@ async def async_send_command( ) -@dataclass -class VacuumEntityDescription(ToggleEntityDescription): +class VacuumEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes vacuum entities.""" -class VacuumEntity(_BaseVacuum, ToggleEntity): +VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { + "status", +} + + +class VacuumEntity( + _BaseVacuum, ToggleEntity, cached_properties=VACUUM_CACHED_PROPERTIES_WITH_ATTR_ +): """Representation of a vacuum cleaner robot.""" @callback @@ -430,7 +463,7 @@ def add_to_platform_start( entity_description: VacuumEntityDescription _attr_status: str | None = None - @property + @cached_property def status(self) -> str | None: """Return the status of the vacuum cleaner.""" return self._attr_status @@ -451,7 +484,7 @@ def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data = super().state_attributes - if self.supported_features & VacuumEntityFeature.STATUS: + if VacuumEntityFeature.STATUS in self.supported_features_compat: data[ATTR_STATUS] = self.status return data @@ -490,18 +523,24 @@ async def async_start_pause(self, **kwargs: Any) -> None: await self.hass.async_add_executor_job(partial(self.start_pause, **kwargs)) -@dataclass -class StateVacuumEntityDescription(EntityDescription): +class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes vacuum entities.""" -class StateVacuumEntity(_BaseVacuum): +STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { + "state", +} + + +class StateVacuumEntity( + _BaseVacuum, cached_properties=STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ +): """Representation of a vacuum cleaner robot that supports states.""" entity_description: StateVacuumEntityDescription _attr_state: str | None = None - @property + @cached_property def state(self) -> str | None: """Return the state of the vacuum cleaner.""" return self._attr_state diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index aab35b4207739a..25f3822bd35549 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -14,6 +14,14 @@ turn_off: supported_features: - vacuum.VacuumEntityFeature.TURN_OFF +toggle: + target: + entity: + domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.TURN_OFF + - vacuum.VacuumEntityFeature.TURN_ON + stop: target: entity: diff --git a/homeassistant/components/vacuum/significant_change.py b/homeassistant/components/vacuum/significant_change.py new file mode 100644 index 00000000000000..5699050c7cb7b5 --- /dev/null +++ b/homeassistant/components/vacuum/significant_change.py @@ -0,0 +1,59 @@ +"""Helper to test significant Vacuum state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name != ATTR_BATTERY_LEVEL: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 3c018fc1a891d8..15ba20760606db 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -48,6 +48,10 @@ "name": "[%key:common::action::turn_off%]", "description": "Stops the current cleaning task and returns to its dock." }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles the vacuum cleaner on/off." + }, "stop": { "name": "[%key:common::action::stop%]", "description": "Stops the current cleaning task." diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index 05085c24424572..00c25897d1c415 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -41,14 +41,14 @@ def is_on(self) -> bool | None: return self.coordinator.data.get_metric(self.entity_description.metric_key) == 1 -@dataclass +@dataclass(frozen=True) class ValloxMetricKeyMixin: """Dataclass to allow defining metric_key without a default value.""" metric_key: str -@dataclass +@dataclass(frozen=True) class ValloxBinarySensorEntityDescription( BinarySensorEntityDescription, ValloxMetricKeyMixin ): diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 2f420096c74800..e58c3ebd88dc92 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -11,11 +11,7 @@ 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 @@ async def _async_set_preset_mode_internal(self, preset_mode: str) -> bool: 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/number.py b/homeassistant/components/vallox/number.py index ce43ca9c3fb8b7..fa5dfff4a6d725 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -60,14 +60,14 @@ async def async_set_native_value(self, value: float) -> None: await self.coordinator.async_request_refresh() -@dataclass +@dataclass(frozen=True) class ValloxMetricMixin: """Holds Vallox metric key.""" metric_key: str -@dataclass +@dataclass(frozen=True) class ValloxNumberEntityDescription(NumberEntityDescription, ValloxMetricMixin): """Describes Vallox number entity.""" diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index ee0e1e432041a4..af5994b66d9e59 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -125,7 +125,7 @@ def native_value(self) -> StateType: return VALLOX_CELL_STATE_TO_STR.get(super_native_value) -@dataclass +@dataclass(frozen=True) class ValloxSensorEntityDescription(SensorEntityDescription): """Describes Vallox sensor entity.""" diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index acc6a31f158181..e3ade9a55c4ad3 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/vallox/switch.py b/homeassistant/components/vallox/switch.py index 194659d40cd4b1..8e7835e0bd76a7 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -63,14 +63,14 @@ async def _set_value(self, value: bool) -> None: await self.coordinator.async_request_refresh() -@dataclass +@dataclass(frozen=True) class ValloxMetricKeyMixin: """Dataclass to allow defining metric_key without a default value.""" metric_key: str -@dataclass +@dataclass(frozen=True) class ValloxSwitchEntityDescription(SwitchEntityDescription, ValloxMetricKeyMixin): """Describes Vallox switch entity.""" diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py new file mode 100644 index 00000000000000..9521d597303565 --- /dev/null +++ b/homeassistant/components/valve/__init__.py @@ -0,0 +1,270 @@ +"""Support for Valve devices.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from enum import IntFlag, StrEnum +import logging +from typing import Any, final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, + SERVICE_STOP_VALVE, + SERVICE_TOGGLE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "valve" +SCAN_INTERVAL = timedelta(seconds=15) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + + +class ValveDeviceClass(StrEnum): + """Device class for valve.""" + + # Refer to the valve dev docs for device class descriptions + WATER = "water" + GAS = "gas" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(ValveDeviceClass)) + + +# mypy: disallow-any-generics +class ValveEntityFeature(IntFlag): + """Supported features of the valve entity.""" + + OPEN = 1 + CLOSE = 2 + SET_POSITION = 4 + STOP = 8 + + +ATTR_CURRENT_POSITION = "current_position" +ATTR_POSITION = "position" + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Track states and offer events for valves.""" + component = hass.data[DOMAIN] = EntityComponent[ValveEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_OPEN_VALVE, {}, "async_handle_open_valve", [ValveEntityFeature.OPEN] + ) + + component.async_register_entity_service( + SERVICE_CLOSE_VALVE, {}, "async_handle_close_valve", [ValveEntityFeature.CLOSE] + ) + + component.async_register_entity_service( + SERVICE_SET_VALVE_POSITION, + { + vol.Required(ATTR_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_set_valve_position", + [ValveEntityFeature.SET_POSITION], + ) + + component.async_register_entity_service( + SERVICE_STOP_VALVE, {}, "async_stop_valve", [ValveEntityFeature.STOP] + ) + + component.async_register_entity_service( + SERVICE_TOGGLE, + {}, + "async_toggle", + [ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE], + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[ValveEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[ValveEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass(frozen=True, kw_only=True) +class ValveEntityDescription(EntityDescription): + """A class that describes valve entities.""" + + device_class: ValveDeviceClass | None = None + reports_position: bool = False + + +class ValveEntity(Entity): + """Base class for valve entities.""" + + entity_description: ValveEntityDescription + _attr_current_valve_position: int | None = None + _attr_device_class: ValveDeviceClass | None + _attr_is_closed: bool | None = None + _attr_is_closing: bool | None = None + _attr_is_opening: bool | None = None + _attr_reports_position: bool + _attr_supported_features: ValveEntityFeature = ValveEntityFeature(0) + + __is_last_toggle_direction_open = True + + @property + def reports_position(self) -> bool: + """Return True if entity reports position, False otherwise.""" + if hasattr(self, "_attr_reports_position"): + return self._attr_reports_position + if hasattr(self, "entity_description"): + return self.entity_description.reports_position + raise ValueError(f"'reports_position' not set for {self.entity_id}.") + + @property + def current_valve_position(self) -> int | None: + """Return current position of valve. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._attr_current_valve_position + + @property + def device_class(self) -> ValveDeviceClass | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + + @property + @final + def state(self) -> str | None: + """Return the state of the valve.""" + reports_position = self.reports_position + if self.is_opening: + self.__is_last_toggle_direction_open = True + return STATE_OPENING + if self.is_closing: + self.__is_last_toggle_direction_open = False + return STATE_CLOSING + if reports_position is True: + if (current_valve_position := self.current_valve_position) is None: + return None + position_zero = current_valve_position == 0 + return STATE_CLOSED if position_zero else STATE_OPEN + if (closed := self.is_closed) is None: + return None + return STATE_CLOSED if closed else STATE_OPEN + + @final + @property + def state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + + return {ATTR_CURRENT_POSITION: self.current_valve_position} + + @property + def supported_features(self) -> ValveEntityFeature: + """Flag supported features.""" + return self._attr_supported_features + + @property + def is_opening(self) -> bool | None: + """Return if the valve is opening or not.""" + return self._attr_is_opening + + @property + def is_closing(self) -> bool | None: + """Return if the valve is closing or not.""" + return self._attr_is_closing + + @property + def is_closed(self) -> bool | None: + """Return if the valve is closed or not.""" + return self._attr_is_closed + + def open_valve(self) -> None: + """Open the valve.""" + raise NotImplementedError() + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.hass.async_add_executor_job(self.open_valve) + + @final + async def async_handle_open_valve(self) -> None: + """Open the valve.""" + if self.supported_features & ValveEntityFeature.SET_POSITION: + return await self.async_set_valve_position(100) + await self.async_open_valve() + + def close_valve(self) -> None: + """Close valve.""" + raise NotImplementedError() + + async def async_close_valve(self) -> None: + """Close valve.""" + await self.hass.async_add_executor_job(self.close_valve) + + @final + async def async_handle_close_valve(self) -> None: + """Close the valve.""" + if self.supported_features & ValveEntityFeature.SET_POSITION: + return await self.async_set_valve_position(0) + await self.async_close_valve() + + async def async_toggle(self) -> None: + """Toggle the entity.""" + if self.supported_features & ValveEntityFeature.STOP and ( + self.is_closing or self.is_opening + ): + return await self.async_stop_valve() + if self.is_closed: + return await self.async_handle_open_valve() + if self.__is_last_toggle_direction_open: + return await self.async_handle_close_valve() + return await self.async_handle_open_valve() + + def set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + raise NotImplementedError() + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + await self.hass.async_add_executor_job(self.set_valve_position, position) + + def stop_valve(self) -> None: + """Stop the valve.""" + raise NotImplementedError() + + async def async_stop_valve(self) -> None: + """Stop the valve.""" + await self.hass.async_add_executor_job(self.stop_valve) diff --git a/homeassistant/components/valve/manifest.json b/homeassistant/components/valve/manifest.json new file mode 100644 index 00000000000000..28563f0976cfb9 --- /dev/null +++ b/homeassistant/components/valve/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "valve", + "name": "Valve", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/valve", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/valve/services.yaml b/homeassistant/components/valve/services.yaml new file mode 100644 index 00000000000000..936599818f1047 --- /dev/null +++ b/homeassistant/components/valve/services.yaml @@ -0,0 +1,45 @@ +# Describes the format for available valve services + +open_valve: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.OPEN + +close_valve: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.CLOSE + +toggle: + target: + entity: + domain: valve + supported_features: + - - valve.ValveEntityFeature.CLOSE + - valve.ValveEntityFeature.OPEN + +set_valve_position: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.SET_POSITION + fields: + position: + required: true + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + +stop_valve: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.STOP diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json new file mode 100644 index 00000000000000..b86ec371b34ec8 --- /dev/null +++ b/homeassistant/components/valve/strings.json @@ -0,0 +1,54 @@ +{ + "title": "Valve", + "entity_component": { + "_": { + "name": "[%key:component::valve::title%]", + "state": { + "open": "[%key:common::state::open%]", + "opening": "Opening", + "closed": "[%key:common::state::closed%]", + "closing": "Closing", + "stopped": "Stopped" + }, + "state_attributes": { + "current_position": { + "name": "Position" + } + } + }, + "water": { + "name": "Water" + }, + "gas": { + "name": "Gas" + } + }, + "services": { + "open_valve": { + "name": "[%key:common::action::open%]", + "description": "Opens a valve." + }, + "close_valve": { + "name": "[%key:common::action::close%]", + "description": "Closes a valve." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles a valve open/closed." + }, + "set_valve_position": { + "name": "Set position", + "description": "Moves a valve to a specific position.", + "fields": { + "position": { + "name": "Position", + "description": "Target position." + } + } + }, + "stop_valve": { + "name": "[%key:common::action::stop%]", + "description": "Stops the valve movement." + } + } +} diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index b2b1cb316244d7..c23c1d5924e08d 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -119,9 +119,11 @@ async def set_memo_text(call: ServiceCall) -> None: """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/velbus/diagnostics.py b/homeassistant/components/velbus/diagnostics.py index f6015abd1f833d..5b991fa35fba3a 100644 --- a/homeassistant/components/velbus/diagnostics.py +++ b/homeassistant/components/velbus/diagnostics.py @@ -48,7 +48,7 @@ def _build_module_diagnostics_info(module: VelbusModule) -> dict[str, Any]: def _build_channels_diagnostics_info( - channels: dict[str, VelbusChannel] + channels: dict[str, VelbusChannel], ) -> dict[str, Any]: """Build diagnostics info for all channels.""" data: dict[str, Any] = {} diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 45220e1a9b477e..1a99f796eb24d8 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -48,7 +48,7 @@ async def _on_update(self) -> None: def api_call( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 3c773e39e33bfc..1f0dd001853d5b 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.10.2"], + "requirements": ["velbus-aio==2023.11.0"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index b43ee39ed4e4d8..d6a5f540c06ccb 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,7 +1,7 @@ """Support for VELUX KLF 200 devices.""" import logging -from pyvlx import OpeningDevice, PyVLX, PyVLXException +from pyvlx import Node, PyVLX, PyVLXException import voluptuous as vol from homeassistant.const import ( @@ -90,7 +90,7 @@ class VeluxEntity(Entity): _attr_should_poll = False - def __init__(self, node: OpeningDevice) -> None: + def __init__(self, node: Node) -> None: """Initialize the Velux device.""" self.node = node self._attr_unique_id = node.serial_number diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 48c09a2b3c21c5..c8fb2aafb96c48 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -1,7 +1,7 @@ """Support for Velux covers.""" from __future__ import annotations -from typing import Any +from typing import Any, cast from pyvlx import OpeningDevice, Position from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter, Window @@ -40,6 +40,7 @@ class VeluxCover(VeluxEntity, CoverEntity): """Representation of a Velux cover.""" _is_blind = False + node: OpeningDevice def __init__(self, node: OpeningDevice) -> None: """Initialize VeluxCover.""" @@ -86,7 +87,7 @@ def current_cover_position(self) -> int: def current_cover_tilt_position(self) -> int | None: """Return the current position of the cover.""" if self._is_blind: - return 100 - self.node.orientation.position_percent + return 100 - cast(Blind, self.node).orientation.position_percent return None @property @@ -116,20 +117,20 @@ async def async_stop_cover(self, **kwargs: Any) -> None: async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close cover tilt.""" - await self.node.close_orientation(wait_for_completion=False) + await cast(Blind, self.node).close_orientation(wait_for_completion=False) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open cover tilt.""" - await self.node.open_orientation(wait_for_completion=False) + await cast(Blind, self.node).open_orientation(wait_for_completion=False) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" - await self.node.stop_orientation(wait_for_completion=False) + await cast(Blind, self.node).stop_orientation(wait_for_completion=False) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move cover tilt to a specific position.""" position_percent = 100 - kwargs[ATTR_TILT_POSITION] orientation = Position(position_percent=position_percent) - await self.node.set_orientation( + await cast(Blind, self.node).set_orientation( orientation=orientation, wait_for_completion=False ) diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index a600aceedd20af..a6d63436ecf6fe 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -35,6 +35,8 @@ class VeluxLight(VeluxEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_color_mode = ColorMode.BRIGHTNESS + node: LighteningDevice + @property def brightness(self): """Return the current brightness.""" diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 0495ff80a43841..901034aa387ba3 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], - "requirements": ["pyvlx==0.2.20"] + "requirements": ["pyvlx==0.2.21"] } diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py index 7125dfd45400a5..1e31fb9407b16f 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -65,7 +65,7 @@ } -@dataclass +@dataclass(frozen=True) class VenstarSensorTypeMixin: """Mixin for sensor required keys.""" @@ -74,7 +74,7 @@ class VenstarSensorTypeMixin: uom_fn: Callable[[Any], str | None] -@dataclass +@dataclass(frozen=True) class VenstarSensorEntityDescription(SensorEntityDescription, VenstarSensorTypeMixin): """Base description of a Sensor entity.""" diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index a844adc2156378..92dfac211fbba8 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/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index c300f599faaca9..00b45e00b11087 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -44,7 +44,7 @@ def new_options(lights: list[int], exclude: list[int]) -> dict[str, list[int]]: def options_schema( - options: Mapping[str, Any] | None = None + options: Mapping[str, Any] | None = None, ) -> dict[vol.Optional, type[str]]: """Return options schema.""" options = options or {} diff --git a/homeassistant/components/version/__init__.py b/homeassistant/components/version/__init__.py index 878ed3d0138349..f05c214744980a 100644 --- a/homeassistant/components/version/__init__.py +++ b/homeassistant/components/version/__init__.py @@ -44,6 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: image=entry.data[CONF_IMAGE], board=BOARD_MAP[board], channel=entry.data[CONF_CHANNEL].lower(), + timeout=30, ), ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/version/const.py b/homeassistant/components/version/const.py index 2dcb0028b274e3..0b39ecee604cc5 100644 --- a/homeassistant/components/version/const.py +++ b/homeassistant/components/version/const.py @@ -66,6 +66,7 @@ "RaspberryPi 3 64bit": "rpi3-64", "RaspberryPi 4": "rpi4", "RaspberryPi 4 64bit": "rpi4-64", + "RaspberryPi 5": "rpi5-64", "ASUS Tinkerboard": "tinker", "ODROID C2": "odroid-c2", "ODROID C4": "odroid-c4", @@ -112,6 +113,7 @@ "raspberrypi3", "raspberrypi4-64", "raspberrypi4", + "raspberrypi5-64", "tinker", ] diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index a0e5b9da52e28d..b2fd090e781024 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -18,9 +18,21 @@ "ESWL01": "switch", "ESWL03": "switch", "ESO15-TB": "outlet", + "LV-PUR131S": "fan", + "Core200S": "fan", + "Core300S": "fan", + "Core400S": "fan", + "Core600S": "fan", + "Vital200S": "fan", + "Vital100S": "fan", + "ESD16": "walldimmer", + "ESWD16": "walldimmer", + "ESL100": "bulb-dimmable", + "ESL100CW": "bulb-tunable-white", } SKU_TO_BASE_DEVICE = { + # Air Purifiers "LV-PUR131S": "LV-PUR131S", "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S "Core200S": "Core200S", diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 326e7daf12cf9d..f0d4d02a9a3759 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -11,26 +11,16 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .common import VeSyncDevice -from .const import DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS +from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "LV-PUR131S": "fan", - "Core200S": "fan", - "Core300S": "fan", - "Core400S": "fan", - "Core600S": "fan", - "Vital200S": "fan", - "Vital100S": "fan", -} - FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" FAN_MODE_PET = "pet" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index e6cc979e8082ef..040e9d5696dcdb 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -14,17 +14,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_LIGHTS +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "ESD16": "walldimmer", - "ESWD16": "walldimmer", - "ESL100": "bulb-dimmable", - "ESL100CW": "bulb-tunable-white", -} - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index fb892acfd4fa49..ff3f56dd184531 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -1,7 +1,7 @@ { "domain": "vesync", "name": "VeSync", - "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey"], + "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey", "@cdnninja"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index f3612c2d011974..97a557ef49f849 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -35,25 +35,25 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class VeSyncSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], StateType] -@dataclass +@dataclass(frozen=True) class VeSyncSensorEntityDescription( SensorEntityDescription, VeSyncSensorEntityDescriptionMixin ): """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 7a297ca8113e70..603a42bae41ad5 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -10,10 +10,15 @@ 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 ( @@ -32,14 +37,14 @@ _TOKEN_FILENAME = "vicare_token.save" -@dataclass() +@dataclass(frozen=True) class ViCareRequiredKeysMixin: """Mixin for required keys.""" - value_getter: Callable[[Device], bool] + value_getter: Callable[[Device], Any] -@dataclass() +@dataclass(frozen=True) class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin): """Mixin for required keys with setter.""" @@ -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 4e3d8d05f97709..f3cf585b4708cf 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,11 +1,16 @@ """Viessmann ViCare sensor device.""" from __future__ import annotations +from collections.abc import Callable 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,29 +30,31 @@ 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__) -@dataclass +@dataclass(frozen=True) class ViCareBinarySensorEntityDescription( BinarySensorEntityDescription, ViCareRequiredKeysMixin ): """Describes ViCare binary sensor entity.""" + value_getter: Callable[[PyViCareDevice], bool] + 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 +63,7 @@ class 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 +73,7 @@ class 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 +82,27 @@ class 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 +110,67 @@ class 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, +) -> 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 + ) + ) + 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[ViCareBinarySensor]: + """Create device specific ViCare binary sensor entities.""" + + return [ + ViCareBinarySensor( + device, device_config, - entity_description, + description, ) - return None + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device) + ] -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_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 +180,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, - ) - 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 + device_config, ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No compressors found") - - async_add_entities(entities) + ) class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): @@ -195,31 +197,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 2516446a94e71b..8f11fdf0ac587c 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import logging +from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, @@ -26,10 +27,8 @@ _LOGGER = logging.getLogger(__name__) -BUTTON_DHW_ACTIVATE_ONETIME_CHARGE = "activate_onetimecharge" - -@dataclass +@dataclass(frozen=True) class ViCareButtonEntityDescription( ButtonEntityDescription, ViCareRequiredKeysMixinWithSet ): @@ -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 @@ class 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 @@ def press(self) -> None: _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 d306cc6604d074..2bb0a19924ec6c 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -5,6 +5,9 @@ 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 @@ 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 @@ } -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 @@ def update(self) -> None: 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,53 @@ def preset_mode(self): 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: - # We can't deactivate "normal" + _LOGGER.debug("Current preset %s", self._current_program) + if self._current_program and self._current_program not in [ + VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_REDUCED, + VICARE_PROGRAM_STANDBY, + ]: + # We can't deactivate "normal", "reduced" or "standby" + _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 not in [ + VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_REDUCED, + VICARE_PROGRAM_STANDBY, + ]: + # And we can't explicitly activate "normal", "reduced" or "standby", 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 5b2d3afa427f3e..87bfcf7b146d90 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 @@ _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 @@ async def async_step_user( 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 @@ async def async_step_user( 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 546f18985e8ce5..3ed81ab587a612 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -6,10 +6,11 @@ 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 089f9c062b86c2..af35c7bf8ddc27 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 d71ccdbb12c821..97c4b91022d7bf 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.28.1"] + "requirements": ["PyViCare==2.32.0"] } diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py new file mode 100644 index 00000000000000..d4dd0437b04521 --- /dev/null +++ b/homeassistant/components/vicare/number.py @@ -0,0 +1,223 @@ +"""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 ( + NumberDeviceClass, + 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(frozen=True) +class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysMixin): + """Describes ViCare number entity.""" + + value_getter: Callable[[PyViCareDevice], float] + 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, + device_class=NumberDeviceClass.TEMPERATURE, + 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, + ), + ViCareNumberEntityDescription( + key="normal_temperature", + translation_key="normal_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("normal"), + value_setter=lambda api, value: api.setProgramTemperature("normal", value), + min_value_getter=lambda api: api.getProgramMinTemperature("normal"), + max_value_getter=lambda api: api.getProgramMaxTemperature("normal"), + stepping_getter=lambda api: api.getProgramStepping("normal"), + ), + ViCareNumberEntityDescription( + key="reduced_temperature", + translation_key="reduced_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("reduced"), + value_setter=lambda api, value: api.setProgramTemperature("reduced", value), + min_value_getter=lambda api: api.getProgramMinTemperature("reduced"), + max_value_getter=lambda api: api.getProgramMaxTemperature("reduced"), + stepping_getter=lambda api: api.getProgramStepping("reduced"), + ), + ViCareNumberEntityDescription( + key="comfort_temperature", + translation_key="comfort_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("comfort"), + value_setter=lambda api, value: api.setProgramTemperature("comfort", value), + min_value_getter=lambda api: api.getProgramMinTemperature("comfort"), + max_value_getter=lambda api: api.getProgramMaxTemperature("comfort"), + stepping_getter=lambda api: api.getProgramStepping("comfort"), + ), +) + + +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 325f3bf2d07158..142e3cbabfaab3 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -6,8 +6,11 @@ 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.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 @@ 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__) @@ -53,17 +58,17 @@ } -@dataclass +@dataclass(frozen=True) 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ), 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) -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] +def _build_entities( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareSensor]: + """Create ViCare sensor entities for a device.""" - entities = [] - for description in GLOBAL_SENSORS: - entity = await hass.async_add_executor_job( - _build_entity, - description.name, - api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, + entities: list[ViCareSensor] = _build_entities_for_device(device, device_config) + entities.extend( + _build_entities_for_component( + get_circuits(device), device_config, CIRCUIT_SENSORS ) - if entity is not None: - entities.append(entity) - - try: - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, api.circuits, config_entry + ) + 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 ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No circuits found") + ) + return entities + + +def _build_entities_for_device( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareSensor]: + """Create device specific ViCare sensor entities.""" - try: - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, api.burners, config_entry + return [ + ViCareSensor( + device, + device_config, + description, ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No burners found") + 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.""" - try: - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, api.compressors, config_entry + return [ + ViCareSensor( + component, + device_config, + description, ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No compressors found") + for component in components + for description in entity_descriptions + if is_supported(description.key, description, component) + ] + - async_add_entities(entities) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create the ViCare sensor devices.""" + api: PyViCareDevice = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config: PyViCareDeviceConfig = 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 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 056a4df7920e7c..6c08215a9c1949 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,278 @@ }, "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" + } + }, + "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 19a75c00962458..a084eee383b3f4 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 @@ -17,10 +21,42 @@ def is_supported( try: entity_description.value_getter(vicare_device) _LOGGER.debug("Found entity %s", name) + return True except PyViCareNotSupportedFeatureError: - _LOGGER.info("Feature not supported %s", name) - return False + _LOGGER.debug("Feature not supported %s", name) except AttributeError as error: - _LOGGER.debug("Attribute Error %s: %s", name, error) - return False - return True + _LOGGER.debug("Feature not supported %s: %s", name, error) + return False + + +def get_burners(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: + """Return the list of burners.""" + try: + return device.burners + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("No burners found") + except AttributeError as error: + _LOGGER.debug("No burners found: %s", error) + return [] + + +def get_circuits(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: + """Return the list of circuits.""" + try: + return device.circuits + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("No circuits found") + except AttributeError as error: + _LOGGER.debug("No circuits found: %s", error) + return [] + + +def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: + """Return the list of compressors.""" + try: + return device.compressors + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("No compressors found") + except AttributeError as error: + _LOGGER.debug("No compressors found: %s", error) + return [] diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index db8a959f4aec46..66a90ca065bc35 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 .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 @@ } -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/sensor.py b/homeassistant/components/vilfo/sensor.py index 511e25bbfbaa8b..c72edf1b7db717 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -24,14 +24,14 @@ ) -@dataclass +@dataclass(frozen=True) class VilfoRequiredKeysMixin: """Mixin for required keys.""" api_key: str -@dataclass +@dataclass(frozen=True) class VilfoSensorEntityDescription(SensorEntityDescription, VilfoRequiredKeysMixin): """Describes Vilfo sensor entity.""" diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json index d559e3a6716c3f..f2c4c38780b439 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 0ff64eeda53e23..6091cd72f3fa63 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/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index ef1df676a2dde2..b84676776f56e4 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -45,7 +45,7 @@ async def async_setup_entry( def catch_vlc_errors( - func: Callable[Concatenate[_VlcDeviceT, _P], Awaitable[None]] + func: Callable[Concatenate[_VlcDeviceT, _P], Awaitable[None]], ) -> Callable[Concatenate[_VlcDeviceT, _P], Coroutine[Any, Any, None]]: """Catch VLC errors.""" diff --git a/homeassistant/components/vlc_telnet/strings.json b/homeassistant/components/vlc_telnet/strings.json index 3a22bd0660283f..c0cacc734d36b4 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/button.py b/homeassistant/components/vodafone_station/button.py index 7f93f8023efab0..3840af3d593f48 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -20,7 +20,7 @@ from .coordinator import VodafoneStationRouter -@dataclass +@dataclass(frozen=True) class VodafoneStationBaseEntityDescriptionMixin: """Mixin to describe a Button entity.""" @@ -28,7 +28,7 @@ class VodafoneStationBaseEntityDescriptionMixin: is_suitable: Callable[[dict], bool] -@dataclass +@dataclass(frozen=True) class VodafoneStationEntityDescription( ButtonEntityDescription, VodafoneStationBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index a2cddcf9a65699..ff51f009f3cb5b 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -97,6 +97,9 @@ async def _async_update_data(self) -> 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 @@ async def _async_update_data(self) -> 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 2a1814c83d09dd..20ea4db057e219 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 1bda3b1595d488..b383c2d193a7cb 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -24,17 +24,17 @@ NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] -@dataclass +@dataclass(frozen=True) 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 -@dataclass +@dataclass(frozen=True, kw_only=True) class VodafoneStationEntityDescription( VodafoneStationBaseEntityDescription, SensorEntityDescription ): diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index aaaa27a3614c57..fab266ac47f86b 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 6ea972686845f6..11f70c631f1f69 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -5,10 +5,12 @@ 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.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 @@ def __init__(self, hass: HomeAssistant, devices: VoIPDevices) -> None: 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 @@ async def _run_pipeline( ) -> 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 @@ async def stt_stream(): ), 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 @@ def _event_callback(self, event: PipelineEvent): 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 @@ async def _send_tts(self, media_id: str) -> None: 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 @@ async def _send_tts(self, media_id: str) -> None: 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 ba283a3af37461..32552ad738698d 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 b3c5a9b4910a85..96d66bb439598d 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -68,7 +68,7 @@ def _require_authentication( - func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any] + func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any], ) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]: """Authenticate with decorator using Wallbox API.""" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 9694e13103ce18..76cf83169596ed 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -35,10 +35,10 @@ 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 +@dataclass(frozen=True) class WallboxNumberEntityDescriptionMixin: """Load entities from different handlers.""" @@ -47,7 +47,7 @@ class WallboxNumberEntityDescriptionMixin: set_value_fn: Callable[[WallboxCoordinator], Callable[[float], Awaitable[None]]] -@dataclass +@dataclass(frozen=True) class WallboxNumberEntityDescription( NumberEntityDescription, WallboxNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 4a1cf365bb1859..5a825722d537c0 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -51,7 +51,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class WallboxSensorEntityDescription(SensorEntityDescription): """Describes Wallbox sensor entity.""" diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index f5731da2a7e221..d742fd72858f41 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/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index d94a2e19f673ac..43be729e10f86b 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -153,7 +153,7 @@ async def async_setup_platform( ) -@dataclass +@dataclass(frozen=True) class WAQIMixin: """Mixin for required keys.""" @@ -161,7 +161,7 @@ class WAQIMixin: value_fn: Callable[[WAQIAirQuality], StateType] -@dataclass +@dataclass(frozen=True) class WAQISensorEntityDescription(SensorEntityDescription, WAQIMixin): """Describes WAQI sensor entity.""" diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 9e796092f6a04a..e5cf2cc2d3ce59 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -29,12 +28,23 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + DEFAULT_MIN_TEMP = 110 DEFAULT_MAX_TEMP = 140 @@ -66,9 +76,19 @@ class WaterHeaterEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the WaterHeaterEntityFeature enum instead. -SUPPORT_TARGET_TEMPERATURE = 1 -SUPPORT_OPERATION_MODE = 2 -SUPPORT_AWAY_MODE = 4 +_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum( + WaterHeaterEntityFeature.TARGET_TEMPERATURE, "2025.1" +) +_DEPRECATED_SUPPORT_OPERATION_MODE = DeprecatedConstantEnum( + WaterHeaterEntityFeature.OPERATION_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_AWAY_MODE = DeprecatedConstantEnum( + WaterHeaterEntityFeature.AWAY_MODE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" @@ -156,12 +176,23 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class WaterHeaterEntityEntityDescription(EntityDescription): +class WaterHeaterEntityEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes water heater entities.""" -class WaterHeaterEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "temperature_unit", + "current_operation", + "operation_list", + "current_temperature", + "target_temperature", + "target_temperature_high", + "target_temperature_low", + "is_away_mode_on", +} + + +class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for water heater entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -210,7 +241,7 @@ def capability_attributes(self) -> Mapping[str, Any]: ), } - if self.supported_features & WaterHeaterEntityFeature.OPERATION_MODE: + if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features_compat: data[ATTR_OPERATION_LIST] = self.operation_list return data @@ -246,51 +277,53 @@ def state_attributes(self) -> dict[str, Any]: ), } - if self.supported_features & WaterHeaterEntityFeature.OPERATION_MODE: + supported_features = self.supported_features_compat + + if WaterHeaterEntityFeature.OPERATION_MODE in supported_features: data[ATTR_OPERATION_MODE] = self.current_operation - if self.supported_features & WaterHeaterEntityFeature.AWAY_MODE: + if WaterHeaterEntityFeature.AWAY_MODE in supported_features: is_away = self.is_away_mode_on data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF return data - @property + @cached_property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" return self._attr_temperature_unit - @property + @cached_property def current_operation(self) -> str | None: """Return current operation ie. eco, electric, performance, ...""" return self._attr_current_operation - @property + @cached_property def operation_list(self) -> list[str] | None: """Return the list of available operation modes.""" return self._attr_operation_list - @property + @cached_property def current_temperature(self) -> float | None: """Return the current temperature.""" return self._attr_current_temperature - @property + @cached_property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._attr_target_temperature - @property + @cached_property def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" return self._attr_target_temperature_high - @property + @cached_property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" return self._attr_target_temperature_low - @property + @cached_property def is_away_mode_on(self) -> bool | None: """Return true if away mode is on.""" return self._attr_is_away_mode_on @@ -368,6 +401,19 @@ def supported_features(self) -> WaterHeaterEntityFeature: """Return the list of supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> WaterHeaterEntityFeature: + """Return the supported features as WaterHeaterEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = WaterHeaterEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + async def async_service_away_mode( entity: WaterHeaterEntity, service: ServiceCall diff --git a/homeassistant/components/water_heater/significant_change.py b/homeassistant/components/water_heater/significant_change.py new file mode 100644 index 00000000000000..bacb0232ee3b4f --- /dev/null +++ b/homeassistant/components/water_heater/significant_change.py @@ -0,0 +1,77 @@ +"""Helper to test significant Water Heater state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ( + ATTR_AWAY_MODE, + ATTR_CURRENT_TEMPERATURE, + ATTR_OPERATION_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_CURRENT_TEMPERATURE, + ATTR_TEMPERATURE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_OPERATION_MODE, + ATTR_AWAY_MODE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + ha_unit = hass.config.units.temperature_unit + + for attr_name in changed_attrs: + if attr_name in [ATTR_OPERATION_MODE, ATTR_AWAY_MODE]: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if ha_unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if check_absolute_change(old_attr_value, new_attr_value, absolute_change): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index 4f4206da6ec6ff..12601c0af8333d 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -14,6 +14,7 @@ CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + CONF_SHOW_ON_MAP, CONF_USERNAME, ) from homeassistant.core import callback @@ -23,7 +24,6 @@ from .const import ( CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, - CONF_SHOW_ON_MAP, DOMAIN, LOGGER, ) diff --git a/homeassistant/components/watttime/const.py b/homeassistant/components/watttime/const.py index 5bb8cb50d40e5c..ce2731e783290d 100644 --- a/homeassistant/components/watttime/const.py +++ b/homeassistant/components/watttime/const.py @@ -7,4 +7,3 @@ CONF_BALANCING_AUTHORITY = "balancing_authority" CONF_BALANCING_AUTHORITY_ABBREV = "balancing_authority_abbreviation" -CONF_SHOW_ON_MAP = "show_on_map" diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index 2a0e21ecf4cb7f..ca5b0d06fa23d2 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -10,7 +10,13 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, PERCENTAGE, UnitOfMass +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_SHOW_ON_MAP, + PERCENTAGE, + UnitOfMass, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,12 +26,7 @@ DataUpdateCoordinator, ) -from .const import ( - CONF_BALANCING_AUTHORITY, - CONF_BALANCING_AUTHORITY_ABBREV, - CONF_SHOW_ON_MAP, - DOMAIN, -) +from .const import CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, DOMAIN ATTR_BALANCING_AUTHORITY = "balancing_authority" diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index d04daf2b16019c..bdc8ae4d514408 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -5,11 +5,11 @@ import asyncio from collections.abc import Callable, Iterable from contextlib import suppress -from dataclasses import dataclass from datetime import timedelta from functools import partial import logging from typing import ( + TYPE_CHECKING, Any, Final, Generic, @@ -45,7 +45,7 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import ABCCachedProperties, Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform import homeassistant.helpers.issue_registry as ir @@ -85,6 +85,12 @@ ) from .websocket_api import async_setup as async_setup_ws_api +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) ATTR_CONDITION_CLASS = "condition_class" @@ -135,7 +141,9 @@ 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]" @@ -211,7 +219,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) component.async_register_legacy_entity_service( - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, async_get_forecast_service, required_features=[ @@ -221,6 +229,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 @@ -238,15 +257,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class WeatherEntityDescription(EntityDescription): +class WeatherEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes weather entities.""" -class PostInitMeta(abc.ABCMeta): +class PostInitMeta(ABCCachedProperties): """Meta class which calls __post_init__ after __new__ and __init__.""" - def __call__(cls, *args: Any, **kwargs: Any) -> Any: + def __call__(cls, *args: Any, **kwargs: Any) -> Any: # noqa: N805 ruff bug, ruff does not understand this is a metaclass """Create an instance.""" instance: PostInit = super().__call__(*args, **kwargs) instance.__post_init__(*args, **kwargs) @@ -261,7 +279,29 @@ def __post_init__(self, *args: Any, **kwargs: Any) -> None: """Finish initializing.""" -class WeatherEntity(Entity, PostInit): +CACHED_PROPERTIES_WITH_ATTR_ = { + "native_apparent_temperature", + "native_temperature", + "native_temperature_unit", + "native_dew_point", + "native_pressure", + "native_pressure_unit", + "humidity", + "native_wind_gust_speed", + "native_wind_speed", + "native_wind_speed_unit", + "wind_bearing", + "ozone", + "cloud_coverage", + "uv_index", + "native_visibility", + "native_visibility_unit", + "native_precipitation_unit", + "condition", +} + + +class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ABC for weather data.""" _entity_component_unrecorded_attributes = frozenset({ATTR_FORECAST}) @@ -387,22 +427,22 @@ async def async_internal_added_to_hass(self) -> None: return self.async_registry_entry_updated() - @property + @cached_property def native_apparent_temperature(self) -> float | None: """Return the apparent temperature in native units.""" - return self._attr_native_temperature + return self._attr_native_apparent_temperature - @property + @cached_property def native_temperature(self) -> float | None: """Return the temperature in native units.""" return self._attr_native_temperature - @property + @cached_property def native_temperature_unit(self) -> str | None: """Return the native unit of measurement for temperature.""" return self._attr_native_temperature_unit - @property + @cached_property def native_dew_point(self) -> float | None: """Return the dew point temperature in native units.""" return self._attr_native_dew_point @@ -430,12 +470,12 @@ def _temperature_unit(self) -> str: return self._default_temperature_unit - @property + @cached_property def native_pressure(self) -> float | None: """Return the pressure in native units.""" return self._attr_native_pressure - @property + @cached_property def native_pressure_unit(self) -> str | None: """Return the native unit of measurement for pressure.""" return self._attr_native_pressure_unit @@ -465,22 +505,22 @@ def _pressure_unit(self) -> str: return self._default_pressure_unit - @property + @cached_property def humidity(self) -> float | None: """Return the humidity in native units.""" return self._attr_humidity - @property + @cached_property def native_wind_gust_speed(self) -> float | None: """Return the wind gust speed in native units.""" return self._attr_native_wind_gust_speed - @property + @cached_property def native_wind_speed(self) -> float | None: """Return the wind speed in native units.""" return self._attr_native_wind_speed - @property + @cached_property def native_wind_speed_unit(self) -> str | None: """Return the native unit of measurement for wind speed.""" return self._attr_native_wind_speed_unit @@ -510,32 +550,32 @@ def _wind_speed_unit(self) -> str: return self._default_wind_speed_unit - @property + @cached_property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" return self._attr_wind_bearing - @property + @cached_property def ozone(self) -> float | None: """Return the ozone level.""" return self._attr_ozone - @property + @cached_property def cloud_coverage(self) -> float | None: """Return the Cloud coverage in %.""" return self._attr_cloud_coverage - @property + @cached_property def uv_index(self) -> float | None: """Return the UV index.""" return self._attr_uv_index - @property + @cached_property def native_visibility(self) -> float | None: """Return the visibility in native units.""" return self._attr_native_visibility - @property + @cached_property def native_visibility_unit(self) -> str | None: """Return the native unit of measurement for visibility.""" return self._attr_native_visibility_unit @@ -592,7 +632,7 @@ async def async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" raise NotImplementedError - @property + @cached_property def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" return self._attr_native_precipitation_unit @@ -959,7 +999,7 @@ def state(self) -> str | None: """Return the current state.""" return self.condition - @property + @cached_property def condition(self) -> str | None: """Return the current condition.""" return self._attr_condition @@ -1086,6 +1126,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 00000000000000..4fd22ceb0a9526 --- /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 b2b71396fab442..222dbf596d0f90 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/significant_change.py b/homeassistant/components/weather/significant_change.py new file mode 100644 index 00000000000000..87e1246ce8505d --- /dev/null +++ b/homeassistant/components/weather/significant_change.py @@ -0,0 +1,170 @@ +"""Helper to test significant Weather state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from .const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, +} + +VALID_CARDINAL_DIRECTIONS: list[str] = [ + "n", + "nne", + "ne", + "ene", + "e", + "ese", + "se", + "sse", + "s", + "ssw", + "sw", + "wsw", + "w", + "wnw", + "nw", + "nnw", +] + + +def _cardinal_to_degrees(value: str | int | float | None) -> int | float | None: + """Translate a cardinal direction into azimuth angle (degrees).""" + if not isinstance(value, str): + return value + + try: + return float(360 / 16 * VALID_CARDINAL_DIRECTIONS.index(value.lower())) + except ValueError: + return None + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + # state changes are always significant + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + absolute_change: float | None = None + if attr_name == ATTR_WEATHER_WIND_BEARING: + old_attr_value = _cardinal_to_degrees(old_attr_value) + new_attr_value = _cardinal_to_degrees(new_attr_value) + + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if attr_name in ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_TEMPERATURE, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_TEMPERATURE_UNIT) + ) is not None and unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_WIND_SPEED_UNIT) + ) is None or unit in ( + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, # 1km/h = 0.62mi/s + UnitOfSpeed.FEET_PER_SECOND, # 1km/h = 0.91ft/s + ): + absolute_change = 1.0 + elif unit == UnitOfSpeed.METERS_PER_SECOND: # 1km/h = 0.277m/s + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_CLOUD_COVERAGE, # range 0-100% + ATTR_WEATHER_HUMIDITY, # range 0-100% + ATTR_WEATHER_OZONE, # range ~20-100ppm + ATTR_WEATHER_VISIBILITY, # range 0-240km (150mi) + ATTR_WEATHER_WIND_BEARING, # range 0-359° + ): + absolute_change = 1.0 + + if attr_name == ATTR_WEATHER_UV_INDEX: # range 1-11 + absolute_change = 0.1 + + if attr_name == ATTR_WEATHER_PRESSURE: # local variation of around 100 hpa + if (unit := new_attrs.get(ATTR_WEATHER_PRESSURE_UNIT)) is None or unit in ( + UnitOfPressure.HPA, + UnitOfPressure.MBAR, # 1hPa = 1mbar + UnitOfPressure.MMHG, # 1hPa = 0.75mmHg + ): + absolute_change = 1.0 + elif unit == UnitOfPressure.INHG: # 1hPa = 0.03inHg + absolute_change = 0.05 + + # check for significant attribute value change + if absolute_change is not None: + if check_absolute_change(old_attr_value, new_attr_value, absolute_change): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index f76e93c66c3e11..0b712a4de05bd9 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/sensor.py b/homeassistant/components/weatherflow/sensor.py index f3e5b8744e6e9e..bbdd79e1533b2b 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -46,7 +46,7 @@ from .const import DOMAIN, LOGGER, format_dispatch_call -@dataclass +@dataclass(frozen=True) class WeatherFlowSensorRequiredKeysMixin: """Mixin for required keys.""" @@ -60,7 +60,7 @@ def precipitation_raw_conversion_fn(raw_data: Enum): return raw_data.name.lower() -@dataclass +@dataclass(frozen=True) class WeatherFlowSensorEntityDescription( SensorEntityDescription, WeatherFlowSensorRequiredKeysMixin ): diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index 8f7a98abe04dc7..d075ee34a05be0 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/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index a918ce0f850d03..824c85781ea2f5 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -37,7 +37,7 @@ def __init__( hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(minutes=15), + update_interval=timedelta(minutes=5), ) async def update_supported_data_sets(self): diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index d28a6ff33157db..a6dd40d599338c 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.2"] } diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 5f82ca54283d2c..16f3e5c7ef2266 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -17,7 +17,7 @@ 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/media_player.py b/homeassistant/components/webostv/media_player.py index 61bef8c693cf68..f12b1c08c60b37 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -84,7 +84,7 @@ async def async_setup_entry( def cmd( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index a5e7b73e59e294..1d045d48ba51f9 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 9c2645aec57c62..f7086cc81db9b2 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -17,6 +17,7 @@ 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/commands.py b/homeassistant/components/websocket_api/commands.py index 18688914e8b746..dfd04aa001a574 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -650,7 +650,7 @@ def _template_listener( def _serialize_entity_sources( - entity_infos: dict[str, entity.EntityInfo] + entity_infos: dict[str, entity.EntityInfo], ) -> dict[str, Any]: """Prepare a websocket response from a dict of entity sources.""" return { @@ -778,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 4581b3be773f69..25b6c90d1ba23a 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -255,7 +255,10 @@ def async_handle_exception(self, msg: dict[str, Any], err: Exception) -> None: 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 @@ -268,6 +271,10 @@ def async_handle_exception(self, msg: dict[str, Any], err: Exception) -> None: 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 @@ -276,7 +283,16 @@ def async_handle_exception(self, msg: dict[str, Any], err: Exception) -> None: 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/messages.py b/homeassistant/components/websocket_api/messages.py index 34ca6886b5eddc..3aaeff6a7973cc 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -204,7 +204,7 @@ def _state_diff( for key, value in new_attributes.items(): if old_attributes.get(key) != value: additions.setdefault(COMPRESSED_STATE_ATTRIBUTES, {})[key] = value - if removed := set(old_attributes).difference(new_attributes): + if removed := old_attributes.keys() - new_attributes: # sets are not JSON serializable by default so we convert to list # here if there are any values to avoid jumping into the json_encoder_default # for every state diff with a removed attribute diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index e1c8655c196857..39abdba6e823c7 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -14,10 +14,10 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import async_wemo_dispatcher_connect from .const import SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 2547dc0ad0d859..ecb0c16055c355 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -22,7 +22,7 @@ from .wemo_device import DeviceCoordinator -@dataclass +@dataclass(frozen=True) class AttributeSensorDescription(SensorEntityDescription): """SensorEntityDescription for WeMo AttributeSensor entities.""" diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index c3cad90e04500d..227c0e9f6531f6 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -89,14 +89,14 @@ def washer_state(washer: WasherDryer) -> str | None: return MACHINE_STATE.get(machine_state, None) -@dataclass +@dataclass(frozen=True) class WhirlpoolSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable -@dataclass +@dataclass(frozen=True) class WhirlpoolSensorEntityDescription( SensorEntityDescription, WhirlpoolSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index beca3540e8ecc6..7118701a86889b 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -27,20 +27,13 @@ from .const import ATTR_EXPIRES, ATTR_NAME_SERVERS, ATTR_REGISTRAR, ATTR_UPDATED, DOMAIN -@dataclass -class WhoisSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, 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 06fbfa3621ee69..cfbdb6bdc92c24 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -5,6 +5,7 @@ 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 @@ 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,22 @@ def push_callback(tags_spec, event_spec): self.api.start_monitoring(push_callback) +def async_migrate_unique_id( + hass: HomeAssistant, tag: SensorTag, domain: str, key: str +) -> None: + """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 711c2987735b9d..64a1097bcab25d 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -4,7 +4,7 @@ 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 @@ 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 @@ ) -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 @@ def __init__(self, api, tag, sensor_type): 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 fd9a7898f920f8..8ae20031723b32 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -12,14 +12,19 @@ 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 @@ ) -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 @@ def __init__(self, api, tag, description): 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 df0f72aca186c1..7f4008623b1678 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -10,13 +10,17 @@ 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 @@ ) -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 @@ def __init__(self, api, tag, description: SwitchEntityDescription) -> None: 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/__init__.py b/homeassistant/components/withings/__init__.py index 701f7f444cfd39..f42fb7a57b98d7 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -192,52 +192,67 @@ async def _refresh_token() -> str: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = withings_data + register_lock = asyncio.Lock() + webhooks_registered = False + async def unregister_webhook( _: Any, ) -> None: - LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) - webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - await async_unsubscribe_webhooks(client) - for coordinator in withings_data.coordinators: - coordinator.webhook_subscription_listener(False) + nonlocal webhooks_registered + async with register_lock: + LOGGER.debug( + "Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + await async_unsubscribe_webhooks(client) + for coordinator in withings_data.coordinators: + coordinator.webhook_subscription_listener(False) + webhooks_registered = False async def register_webhook( _: Any, ) -> None: - if cloud.async_active_subscription(hass): - webhook_url = await _async_cloudhook_generate_url(hass, entry) - else: - webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) - url = URL(webhook_url) - if url.scheme != "https" or url.port != 443: - LOGGER.warning( - "Webhook not registered - " - "https and port 443 is required to register the webhook" + nonlocal webhooks_registered + async with register_lock: + if webhooks_registered: + return + if cloud.async_active_subscription(hass): + webhook_url = await _async_cloudhook_generate_url(hass, entry) + else: + webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) + url = URL(webhook_url) + if url.scheme != "https" or url.port != 443: + LOGGER.warning( + "Webhook not registered - " + "https and port 443 is required to register the webhook" + ) + return + + webhook_name = "Withings" + if entry.title != DEFAULT_TITLE: + webhook_name = f"{DEFAULT_TITLE} {entry.title}" + + webhook_register( + hass, + DOMAIN, + webhook_name, + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(withings_data), + allowed_methods=[METH_POST], ) - return - - webhook_name = "Withings" - if entry.title != DEFAULT_TITLE: - webhook_name = f"{DEFAULT_TITLE} {entry.title}" - - webhook_register( - hass, - DOMAIN, - webhook_name, - entry.data[CONF_WEBHOOK_ID], - get_webhook_handler(withings_data), - allowed_methods=[METH_POST], - ) - - await async_subscribe_webhooks(client, webhook_url) - for coordinator in withings_data.coordinators: - coordinator.webhook_subscription_listener(True) - LOGGER.debug("Register Withings webhook: %s", webhook_url) - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) - ) + LOGGER.debug("Registered Withings webhook at hass: %s", webhook_url) + + await async_subscribe_webhooks(client, webhook_url) + for coordinator in withings_data.coordinators: + coordinator.webhook_subscription_listener(True) + LOGGER.debug("Registered Withings webhook at Withings: %s", webhook_url) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + webhooks_registered = True async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + LOGGER.debug("Cloudconnection state changed to %s", state) if state is cloud.CloudConnectionState.CLOUD_CONNECTED: await register_webhook(None) diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index 19572682d1a8d1..132f00936f3219 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 d43ae7da50c0d1..fe5704d119ccac 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 707059a293033b..de053d6a894335 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -58,20 +58,13 @@ from .entity import WithingsEntity -@dataclass -class WithingsMeasurementSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(frozen=True, 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 ] = { @@ -240,23 +233,43 @@ class WithingsMeasurementSensorEntityDescription( translation_key="vascular_age", entity_registry_enabled_default=False, ), + MeasurementType.VISCERAL_FAT: WithingsMeasurementSensorEntityDescription( + key="visceral_fat", + measurement_type=MeasurementType.VISCERAL_FAT, + translation_key="visceral_fat_index", + entity_registry_enabled_default=False, + ), + MeasurementType.ELECTRODERMAL_ACTIVITY_FEET: WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_feet", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_FEET, + translation_key="electrodermal_activity_feet", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + MeasurementType.ELECTRODERMAL_ACTIVITY_LEFT_FOOT: WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_left_foot", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_LEFT_FOOT, + translation_key="electrodermal_activity_left_foot", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + MeasurementType.ELECTRODERMAL_ACTIVITY_RIGHT_FOOT: WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_right_foot", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_RIGHT_FOOT, + translation_key="electrodermal_activity_right_foot", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), } -@dataclass -class WithingsSleepSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(frozen=True, 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 +423,13 @@ class WithingsSleepSensorEntityDescription( ] -@dataclass -class WithingsActivitySensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(frozen=True, 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 +451,11 @@ class WithingsActivitySensorEntityDescription( ), 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 +521,13 @@ class WithingsActivitySensorEntityDescription( WEIGHT_GOAL = "weight" -@dataclass -class WithingsGoalsSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(frozen=True, 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 +558,13 @@ class WithingsGoalsSensorEntityDescription( } -@dataclass -class WithingsWorkoutSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(frozen=True, 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 +596,11 @@ class WithingsWorkoutSensorEntityDescription( ), 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 645ab13530086c..a142dd23eac73f 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -19,8 +19,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed]" + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "Successfully authenticated with Withings." @@ -92,6 +92,18 @@ "vascular_age": { "name": "Vascular age" }, + "visceral_fat_index": { + "name": "Visceral fat index" + }, + "electrodermal_activity_feet": { + "name": "Electrodermal activity feet" + }, + "electrodermal_activity_left_foot": { + "name": "Electrodermal activity left foot" + }, + "electrodermal_activity_right_foot": { + "name": "Electrodermal activity right foot" + }, "breathing_disturbances_intensity": { "name": "Breathing disturbances intensity" }, @@ -158,8 +170,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" @@ -239,8 +251,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/number.py b/homeassistant/components/wiz/number.py index f1212c75f25102..91436674d7fc37 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -22,20 +22,13 @@ from .models import WizData -@dataclass -class WizNumberEntityDescriptionMixin: - """Mixin to describe a WiZ number entity.""" +@dataclass(frozen=True, kw_only=True) +class WizNumberEntityDescription(NumberEntityDescription): + """Class 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 -): - """Class to describe a WiZ number entity.""" + 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: diff --git a/homeassistant/components/wled/helpers.py b/homeassistant/components/wled/helpers.py index 85dcf9ca800f75..b4b5ee4c892237 100644 --- a/homeassistant/components/wled/helpers.py +++ b/homeassistant/components/wled/helpers.py @@ -15,7 +15,7 @@ def wled_exception_handler( - func: Callable[Concatenate[_WLEDEntityT, _P], Coroutine[Any, Any, Any]] + func: Callable[Concatenate[_WLEDEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_WLEDEntityT, _P], Coroutine[Any, Any, None]]: """Decorate WLED calls to handle WLED exceptions. diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 9fb18d3e1136e9..0fa7d464722945 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(frozen=True, 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 7d1431c093bfef..709edaf424ffd8 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -31,20 +31,12 @@ from .models import WLEDEntity -@dataclass -class WLEDSensorEntityDescriptionMixin: - """Mixin for required keys.""" - - value_fn: Callable[[WLEDDevice], datetime | StateType] - - -@dataclass -class WLEDSensorEntityDescription( - SensorEntityDescription, WLEDSensorEntityDescriptionMixin -): +@dataclass(frozen=True, 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 61b9cc450fe1b0..eff6dfab57275b 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 34df0176e29076..73f49a2ad097ff 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -51,7 +51,7 @@ async def async_update_data(): 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 ac5bbad48dc29b..59329ee41dd810 100644 --- a/homeassistant/components/wolflink/const.py +++ b/homeassistant/components/wolflink/const.py @@ -7,6 +7,7 @@ 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 b4d60011658667..2135239b3eb457 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -15,10 +15,11 @@ 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 @@ def __init__(self, coordinator, wolf_object: Parameter, device_id) -> None: 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 558e0aa9ecfb6a..3000570731b579 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -1,14 +1,15 @@ """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_COUNTRY, CONF_LANGUAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import CONF_COUNTRY, CONF_PROVINCE, DOMAIN, PLATFORMS +from .const import CONF_PROVINCE, DOMAIN, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -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 2692c27d58ab75..bda3a576563145 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_COUNTRY, 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 @@ -21,12 +21,12 @@ AddEntitiesCallback, async_get_current_platform, ) -from homeassistant.util import dt as dt_util +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.util import dt as dt_util, slugify from .const import ( ALLOWED_DAYS, CONF_ADD_HOLIDAYS, - CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, CONF_PROVINCE, @@ -72,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() @@ -111,6 +123,25 @@ async def async_setup_entry( LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) except KeyError as unmatched: LOGGER.warning("No holiday found matching %s", unmatched) + async_create_issue( + hass, + DOMAIN, + f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_named_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): @@ -197,21 +228,26 @@ def is_exclude(self, day: str, now: date) -> bool: async def async_update(self) -> None: """Get date and look whether it is a holiday.""" + self._attr_is_on = self.date_is_workday(dt_util.now()) + + async def check_date(self, check_date: date) -> ServiceResponse: + """Service to check if date is workday or not.""" + return {"workday": self.date_is_workday(check_date)} + + def date_is_workday(self, check_date: date) -> bool: + """Check if date is workday.""" # Default is no workday - self._attr_is_on = False + is_workday = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = dt_util.now() + timedelta(days=self._days_offset) + adjusted_date = check_date + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 day_of_week = ALLOWED_DAYS[day] if self.is_include(day_of_week, adjusted_date): - self._attr_is_on = True + is_workday = True if self.is_exclude(day_of_week, adjusted_date): - self._attr_is_on = False + is_workday = 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} + return is_workday diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index c4b1f1ba3fd188..859d3710ca4a5e 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -11,13 +11,15 @@ ConfigFlow, OptionsFlowWithConfigEntry, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_COUNTRY, 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, @@ -31,7 +33,6 @@ from .const import ( ALLOWED_DAYS, CONF_ADD_HOLIDAYS, - CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, CONF_PROVINCE, @@ -46,7 +47,7 @@ ) -def add_province_to_schema( +def add_province_and_language_to_schema( schema: vol.Schema, country: str | None, ) -> vol.Schema: @@ -55,20 +56,36 @@ def add_province_to_schema( return schema all_countries = list_supported_countries(include_aliases=False) - if not all_countries.get(country): - return schema - 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) ) - ), - } + } + + 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, **add_schema}) + 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 +110,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) @@ -125,7 +154,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: DATA_SCHEMA_OPT = vol.Schema( { - vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): SelectSelector( + vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): SelectSelector( SelectSelectorConfig( options=ALLOWED_DAYS, multiple=True, @@ -133,10 +162,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: translation_key="days", ) ), - vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): NumberSelector( - NumberSelectorConfig(min=-10, max=10, step=1, mode=NumberSelectorMode.BOX) - ), - vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): SelectSelector( + vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): SelectSelector( SelectSelectorConfig( options=ALLOWED_DAYS, multiple=True, @@ -144,6 +170,9 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: translation_key="days", ) ), + vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): NumberSelector( + NumberSelectorConfig(min=-10, max=10, step=1, mode=NumberSelectorMode.BOX) + ), vol.Optional(CONF_ADD_HOLIDAYS, default=[]): SelectSelector( SelectSelectorConfig( options=[], @@ -237,7 +266,9 @@ async def async_step_options( ) 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 +329,9 @@ async def async_step_init( 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/const.py b/homeassistant/components/workday/const.py index 20905fb9892080..ad9375830ddcf5 100644 --- a/homeassistant/components/workday/const.py +++ b/homeassistant/components/workday/const.py @@ -12,7 +12,6 @@ DOMAIN = "workday" PLATFORMS = [Platform.BINARY_SENSOR] -CONF_COUNTRY = "country" CONF_PROVINCE = "province" CONF_WORKDAYS = "workdays" CONF_EXCLUDES = "excludes" diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 1c9a533d998c9c..ae7c42c1868b5a 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.39"] } diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index fbed179763e375..905434f76ac046 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -18,7 +18,8 @@ SelectSelectorMode, ) -from .const import CONF_PROVINCE +from .config_flow import validate_custom_dates +from .const import CONF_PROVINCE, CONF_REMOVE_HOLIDAYS class CountryFixFlow(RepairsFlow): @@ -108,6 +109,76 @@ async def async_step_province( ) +class HolidayFixFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__( + self, entry: ConfigEntry, country: str | None, named_holiday: str + ) -> None: + """Create flow.""" + self.entry = entry + self.country: str | None = country + self.named_holiday: str = named_holiday + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_named_holiday() + + async def async_step_named_holiday( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the options step of a fix flow.""" + errors: dict[str, str] = {} + if user_input: + options = dict(self.entry.options) + new_options = {**options, **user_input} + try: + await self.hass.async_add_executor_job( + validate_custom_dates, new_options + ) + except Exception: # pylint: disable=broad-except + errors["remove_holidays"] = "remove_holiday_error" + else: + self.hass.config_entries.async_update_entry( + self.entry, options=new_options + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + remove_holidays = self.entry.options[CONF_REMOVE_HOLIDAYS] + removed_named_holiday = [ + value for value in remove_holidays if value != self.named_holiday + ] + new_schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional(CONF_REMOVE_HOLIDAYS, default=[]): SelectSelector( + SelectSelectorConfig( + options=[], + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + {CONF_REMOVE_HOLIDAYS: removed_named_holiday}, + ) + return self.async_show_form( + step_id="named_holiday", + data_schema=new_schema, + description_placeholders={ + CONF_COUNTRY: self.country if self.country else "-", + CONF_REMOVE_HOLIDAYS: self.named_holiday, + "title": self.entry.title, + }, + errors=errors, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -119,6 +190,10 @@ async def async_create_fix_flow( entry_id = cast(str, entry_id) entry = hass.config_entries.async_get_entry(entry_id) + if data and (holiday := data.get("named_holiday")) and entry: + # Bad named holiday in configuration + return HolidayFixFlow(entry, data.get("country"), holiday) + if data and entry: # Country or province does not exist return CountryFixFlow(entry, data.get("country")) diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index a05ab1fc669e12..bbb76676f96364 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -19,15 +19,17 @@ "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", - "days_offset": "Days offset", - "workdays": "List of workdays", + "excludes": "List of workdays to exclude, notice the keyword `holiday` and read the documentation on how to use it correctly", + "days_offset": "Days offset from current day", + "workdays": "List of working days", "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 or region of country", + "language": "Language to use when configuring named holiday exclusions" } } }, @@ -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%]" } } }, @@ -128,6 +132,26 @@ } } } + }, + "bad_named_holiday": { + "title": "Configured named holiday {remove_holidays} for {title} does not exist", + "fix_flow": { + "step": { + "named_holiday": { + "title": "[%key:component::workday::issues::bad_named_holiday::title%]", + "description": "Remove named holiday `{remove_holidays}` as it can't be found in country {country}.", + "data": { + "remove_holidays": "[%key:component::workday::config::step::options::data::remove_holidays%]" + }, + "data_description": { + "remove_holidays": "[%key:component::workday::config::step::options::data_description::remove_holidays%]" + } + } + }, + "error": { + "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]" + } + } } }, "entity": { diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 33064d21097554..88e490d6dc9de5 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -4,17 +4,31 @@ 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, + Platform.NUMBER, +] + __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup_entry", + "async_unload_entry", ] @@ -25,24 +39,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 00000000000000..4f2c0bb170acb7 --- /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 f6b8ed7389095d..b766fc80c89a93 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 @@ async def async_step_user( errors={"base": "cannot_connect"}, ) - # ASR = automated speech recognition (speech-to-text) - asr_installed = [asr for asr in service.info.asr if asr.installed] - - # TTS = text-to-speech - tts_installed = [tts for tts in service.info.tts if tts.installed] - - # wake-word-detection - wake_installed = [wake for wake in service.info.wake if wake.installed] + if name := service.get_name(): + return self.async_create_entry(title=name, data=user_input) - 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) + return self.async_abort(reason="no_services") - 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 @@ async def async_step_hassio_confirm( 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 @@ async def async_step_hassio_confirm( 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 64b92eb847177b..ea58181a7074d1 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -4,7 +4,7 @@ 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 @@ def __init__(self, host: str, port: int, info: Info) -> None: 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 00000000000000..6865669fbf05eb --- /dev/null +++ b/homeassistant/components/wyoming/devices.py @@ -0,0 +1,141 @@ +"""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_muted: bool = False + pipeline_name: str | None = None + noise_suppression_level: int = 0 + auto_gain: int = 0 + volume_multiplier: float = 1.0 + + _is_active_listener: Callable[[], None] | None = None + _is_muted_listener: Callable[[], None] | None = None + _pipeline_listener: Callable[[], None] | None = None + _audio_settings_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_muted(self, muted: bool) -> None: + """Set muted state.""" + if muted != self.is_muted: + self.is_muted = muted + if self._is_muted_listener is not None: + self._is_muted_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_noise_suppression_level(self, noise_suppression_level: int) -> None: + """Set noise suppression level.""" + if noise_suppression_level != self.noise_suppression_level: + self.noise_suppression_level = noise_suppression_level + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_auto_gain(self, auto_gain: int) -> None: + """Set auto gain amount.""" + if auto_gain != self.auto_gain: + self.auto_gain = auto_gain + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_volume_multiplier(self, volume_multiplier: float) -> None: + """Set auto gain amount.""" + if volume_multiplier != self.volume_multiplier: + self.volume_multiplier = volume_multiplier + if self._audio_settings_listener is not None: + self._audio_settings_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_muted_listener(self, is_muted_listener: Callable[[], None]) -> None: + """Listen for updates to muted status.""" + self._is_muted_listener = is_muted_listener + + @callback + def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None: + """Listen for updates to pipeline.""" + self._pipeline_listener = pipeline_listener + + @callback + def set_audio_settings_listener( + self, audio_settings_listener: Callable[[], None] + ) -> None: + """Listen for updates to audio settings.""" + self._audio_settings_listener = audio_settings_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_muted_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for satellite muted switch.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{self.satellite_id}-mute" + ) + + 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" + ) + + def get_noise_suppression_level_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for noise suppression select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-noise_suppression_level" + ) + + def get_auto_gain_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for auto gain amount.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-auto_gain" + ) + + def get_volume_multiplier_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for microphone volume multiplier.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-volume_multiplier" + ) diff --git a/homeassistant/components/wyoming/entity.py b/homeassistant/components/wyoming/entity.py new file mode 100644 index 00000000000000..5ed890bc60e7dc --- /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 ddb5407e1cea52..7174683fd189a6 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.4.0"], + "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/models.py b/homeassistant/components/wyoming/models.py new file mode 100644 index 00000000000000..dce45d509eb865 --- /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/number.py b/homeassistant/components/wyoming/number.py new file mode 100644 index 00000000000000..5e769eeb06d4d0 --- /dev/null +++ b/homeassistant/components/wyoming/number.py @@ -0,0 +1,102 @@ +"""Number entities for Wyoming integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from homeassistant.components.number import NumberEntityDescription, RestoreNumber +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + +_MAX_AUTO_GAIN: Final = 31 +_MIN_VOLUME_MULTIPLIER: Final = 0.1 +_MAX_VOLUME_MULTIPLIER: Final = 10.0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming number entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + device = item.satellite.device + async_add_entities( + [ + WyomingSatelliteAutoGainNumber(device), + WyomingSatelliteVolumeMultiplierNumber(device), + ] + ) + + +class WyomingSatelliteAutoGainNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent auto gain amount.""" + + entity_description = NumberEntityDescription( + key="auto_gain", + translation_key="auto_gain", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = 0 + _attr_native_max_value = _MAX_AUTO_GAIN + _attr_native_value = 0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None: + await self.async_set_native_value(float(state.state)) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + auto_gain = int(max(0, min(_MAX_AUTO_GAIN, value))) + self._attr_native_value = auto_gain + self.async_write_ha_state() + self._device.set_auto_gain(auto_gain) + + +class WyomingSatelliteVolumeMultiplierNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent microphone volume multiplier.""" + + entity_description = NumberEntityDescription( + key="volume_multiplier", + translation_key="volume_multiplier", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = _MIN_VOLUME_MULTIPLIER + _attr_native_max_value = _MAX_VOLUME_MULTIPLIER + _attr_native_step = 0.1 + _attr_native_value = 1.0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + last_number_data = await self.async_get_last_number_data() + if (last_number_data is not None) and ( + last_number_data.native_value is not None + ): + await self.async_set_native_value(last_number_data.native_value) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + self._attr_native_value = float( + max(_MIN_VOLUME_MULTIPLIER, min(_MAX_VOLUME_MULTIPLIER, value)) + ) + self.async_write_ha_state() + self._device.set_volume_multiplier(self._attr_native_value) diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py new file mode 100644 index 00000000000000..78f57ff4b01ada --- /dev/null +++ b/homeassistant/components/wyoming/satellite.py @@ -0,0 +1,422 @@ +"""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.error import Error +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_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._muted_changed_event = asyncio.Event() + + self.device.set_is_muted_listener(self._muted_changed) + self.device.set_pipeline_listener(self._pipeline_changed) + self.device.set_audio_settings_listener(self._audio_settings_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 muted + while self.device.is_muted: + await self.on_muted() + if not self.is_running: + # Satellite was stopped while waiting to be unmuted + return + + # 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 unmuted + self._muted_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_muted(self) -> None: + """Block until device may be unmated again.""" + await self._muted_changed_event.wait() + + async def on_stopped(self) -> None: + """Run when run() has fully stopped.""" + _LOGGER.debug("Satellite task stopped") + + # ------------------------------------------------------------------------- + + def _muted_changed(self) -> None: + """Run when device muted status changes.""" + if self.device.is_muted: + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + self._muted_changed_event.set() + self._muted_changed_event.clear() + + def _pipeline_changed(self) -> None: + """Run when device pipeline changes.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + def _audio_settings_changed(self) -> None: + """Run when device audio settings.""" + + # 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 (not self.device.is_muted): + 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 self.device.is_muted: + # 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 (not self.device.is_muted): + 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 self.device.is_muted: + # 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 (not self.device.is_muted): + # 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, + audio_settings=assist_pipeline.AudioSettings( + noise_suppression_level=self.device.noise_suppression_level, + auto_gain_dbfs=self.device.auto_gain, + volume_multiplier=self.device.volume_multiplier, + ), + device_id=self.device.device_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) + elif AudioStop.is_type(client_event.type): + # Stop pipeline + _LOGGER.debug("Client requested pipeline to stop") + self._audio_queue.put_nowait(b"") + break + else: + _LOGGER.debug("Unexpected event from satellite: %s", client_event) + + # Ensure task finishes + await _pipeline_task + + _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)) + elif event.type == assist_pipeline.PipelineEventType.ERROR: + # Pipeline error + if event.data: + self.hass.add_job( + self._client.write_event( + Error( + text=event.data["message"], code=event.data["code"] + ).event() + ) + ) + + async def _connect(self) -> None: + """Connect to satellite over TCP.""" + await self._disconnect() + + _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 _disconnect(self) -> None: + """Disconnect if satellite is currently connected.""" + if self._client is None: + return + + _LOGGER.debug("Disconnecting from satellite") + await self._client.disconnect() + self._client = None + + 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 00000000000000..c04bad4bef8ae1 --- /dev/null +++ b/homeassistant/components/wyoming/select.py @@ -0,0 +1,94 @@ +"""Select entities for Wyoming integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +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 + +_NOISE_SUPPRESSION_LEVEL: Final = { + "off": 0, + "low": 1, + "medium": 2, + "high": 3, + "max": 4, +} +_DEFAULT_NOISE_SUPPRESSION_LEVEL: Final = "off" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming select entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + device = item.satellite.device + async_add_entities( + [ + WyomingSatellitePipelineSelect(hass, device), + WyomingSatelliteNoiseSuppressionLevelSelect(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) + + +class WyomingSatelliteNoiseSuppressionLevelSelect( + WyomingSatelliteEntity, SelectEntity, restore_state.RestoreEntity +): + """Entity to represent noise suppression level setting.""" + + entity_description = SelectEntityDescription( + key="noise_suppression_level", + translation_key="noise_suppression_level", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option = _DEFAULT_NOISE_SUPPRESSION_LEVEL + _attr_options = list(_NOISE_SUPPRESSION_LEVEL.keys()) + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None and state.state in self.options: + self._attr_current_option = state.state + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + self._attr_current_option = option + self.async_write_ha_state() + self._device.set_noise_suppression_level(_NOISE_SUPPRESSION_LEVEL[option]) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 20d73d8dc1391c..f2768e45eb8aff 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,46 @@ }, "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_level": { + "name": "Noise suppression level", + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High", + "max": "Max" + } + } + }, + "switch": { + "mute": { + "name": "Mute" + } + }, + "number": { + "auto_gain": { + "name": "Auto gain" + }, + "volume_multiplier": { + "name": "Mic volume" + } } } } diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index e64a2f14667020..8a21ef051fced9 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -14,6 +14,7 @@ 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 00000000000000..7366a52efab6dc --- /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([WyomingSatelliteMuteSwitch(item.satellite.device)]) + + +class WyomingSatelliteMuteSwitch( + WyomingSatelliteEntity, restore_state.RestoreEntity, SwitchEntity +): + """Entity to represent if satellite is muted.""" + + entity_description = SwitchEntityDescription( + key="mute", + translation_key="mute", + 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 off + self._attr_is_on = (state is not None) and (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_muted(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_muted(False) diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index cde771cd330566..f024f925514ff0 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -16,6 +16,7 @@ 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), ] ) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index fce8bbf6327c16..da05e8c9fe112d 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -15,6 +15,7 @@ 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 e011194dc7c82e..0d9a12137ceb4d 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -11,8 +11,8 @@ "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]" + "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/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 3c316fd3f47d50..716d4a04fa7b6c 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -35,7 +35,7 @@ from miio.gateway.gateway import GatewayException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN, Platform +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, 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 @@ -43,7 +43,6 @@ from .const import ( ATTR_AVAILABLE, - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 30fcaa5152a84c..f9248ba5ff3cce 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -6,12 +6,11 @@ from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, MODEL_AIRQUALITYMONITOR_B1, MODEL_AIRQUALITYMONITOR_CGDN1, diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 051ac2ab77896e..e1b06175493326 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -11,13 +11,12 @@ BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL, EntityCategory +from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import VacuumCoordinatorDataAttributes from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, @@ -45,7 +44,7 @@ ATTR_WATER_SHORTAGE = "is_water_shortage" -@dataclass +@dataclass(frozen=True) class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): """A class that describes binary sensor entities.""" diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index e5e11b85e58dc5..4ebbf34f29588c 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -37,7 +37,7 @@ ATTR_RESET_VACUUM_SENSOR_DIRTY = "reset_vacuum_sensor_dirty" -@dataclass +@dataclass(frozen=True) class XiaomiMiioButtonDescription(ButtonEntityDescription): """A class that describes button entities.""" diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 70e6fb5c0b6bed..02e88c6b14ecdf 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -13,7 +13,7 @@ from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac @@ -23,10 +23,8 @@ CONF_CLOUD_PASSWORD, CONF_CLOUD_SUBDEVICES, CONF_CLOUD_USERNAME, - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, - CONF_MAC, CONF_MANUAL, DEFAULT_CLOUD_COUNTRY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 6621e41e7aa7cf..ef9668dbee4efd 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -18,8 +18,6 @@ # Config flow CONF_FLOW_TYPE = "config_flow_device" CONF_GATEWAY = "gateway" -CONF_DEVICE = "device" -CONF_MAC = "mac" CONF_CLOUD_USERNAME = "cloud_username" CONF_CLOUD_PASSWORD = "cloud_password" CONF_CLOUD_COUNTRY = "cloud_country" diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index da860c7045e417..0c87f74a7e6c25 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -8,7 +8,7 @@ from construct.core import ChecksumError from miio import Device, DeviceException -from homeassistant.const import ATTR_CONNECTIONS, CONF_MODEL +from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC, CONF_MODEL from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -17,7 +17,7 @@ DataUpdateCoordinator, ) -from .const import CONF_MAC, DOMAIN, AuthException, SetupException +from .const import DOMAIN, AuthException, SetupException _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index a3bb28e7a8b7a1..3038342621054c 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -30,7 +30,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, CONF_MODEL +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -40,7 +40,6 @@ ) from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, FEATURE_FLAGS_AIRFRESH, @@ -530,9 +529,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 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 +619,6 @@ def operation_mode_class(self): 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 +714,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 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 +799,6 @@ async def async_set_percentage(self, percentage: int) -> None: 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 +945,6 @@ def _handle_coordinator_update(self): 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 +1017,6 @@ def _handle_coordinator_update(self): 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 +1073,6 @@ def _handle_coordinator_update(self): 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/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 0438b606efd9c1..f2660bef68a73b 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -20,13 +20,12 @@ HumidifierEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODE, CONF_MODEL +from homeassistant.const import ATTR_MODE, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import percentage_to_ranged_value from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 1fc032b5c3665f..8d198ae2a8f705 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -33,7 +33,13 @@ LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE, + CONF_HOST, + CONF_MODEL, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo @@ -41,7 +47,6 @@ from homeassistant.util import color, dt as dt_util from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index a8346caa8941ab..3e952c1ab3f601 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -13,6 +13,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE, CONF_MODEL, DEGREE, REVOLUTIONS_PER_MINUTE, @@ -25,7 +26,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, FEATURE_FLAGS_AIRFRESH, @@ -108,14 +108,14 @@ ATTR_VOLUME = "volume" -@dataclass +@dataclass(frozen=True) class XiaomiMiioNumberMixin: """A class that describes number entities.""" method: str -@dataclass +@dataclass(frozen=True) class XiaomiMiioNumberDescription(NumberEntityDescription, XiaomiMiioNumberMixin): """A class that describes number entities.""" @@ -417,7 +417,7 @@ async def async_set_favorite_level(self, level: int = 1) -> bool: async def async_set_fan_level(self, level: int = 1) -> bool: """Set the fan level.""" return await self._try_command( - "Setting the favorite level of the miio device failed.", + "Setting the fan level of the miio device failed.", self._device.set_fan_level, level, ) diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 74ce36ca57a69d..b70dab1921aa94 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -29,12 +29,11 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL, EntityCategory +from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, @@ -72,7 +71,7 @@ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class XiaomiMiioSelectDescription(SelectEntityDescription): """A class that describes select entities.""" diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 17d60e1a9527e6..a8435d6a8a146c 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -28,6 +28,7 @@ ATTR_TEMPERATURE, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, @@ -48,7 +49,6 @@ from . import VacuumCoordinatorDataAttributes from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, @@ -150,7 +150,7 @@ ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT = "sensor_dirty_left" -@dataclass +@dataclass(frozen=True) class XiaomiMiioSensorDescription(SensorEntityDescription): """Class that holds device specific info for a xiaomi aqara or humidifier sensor.""" diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 9bba9f6112314b..68714f1a6ff311 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -21,6 +21,7 @@ ATTR_ENTITY_ID, ATTR_MODE, ATTR_TEMPERATURE, + CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, @@ -31,7 +32,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, @@ -219,7 +219,7 @@ } -@dataclass +@dataclass(frozen=True) class XiaomiMiioSwitchRequiredKeyMixin: """A class that describes switch entities.""" @@ -228,7 +228,7 @@ class XiaomiMiioSwitchRequiredKeyMixin: method_off: str -@dataclass +@dataclass(frozen=True) class XiaomiMiioSwitchDescription( SwitchEntityDescription, XiaomiMiioSwitchRequiredKeyMixin ): diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 34a7b949646878..73e2e54b62f5e0 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -19,6 +19,7 @@ VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -27,7 +28,6 @@ from . import VacuumCoordinatorData from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 7ced3487269366..31851ad3ceb3d0 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -83,7 +83,13 @@ async def async_set_alarm(self, command: str, code: str | None = None) -> None: except YALE_ALL_ERRORS as error: raise HomeAssistantError( f"Could not set alarm for {self.coordinator.entry.data[CONF_NAME]}:" - f" {error}" + f" {error}", + translation_domain=DOMAIN, + translation_key="set_alarm", + translation_placeholders={ + "name": self.coordinator.entry.data[CONF_NAME], + "error": str(error), + }, ) from error if alarm_state: @@ -91,7 +97,9 @@ async def async_set_alarm(self, command: str, code: str | None = None) -> None: self.async_write_ha_state() return raise HomeAssistantError( - "Could not change alarm check system ready for arming." + "Could not change alarm, check system ready for arming", + translation_domain=DOMAIN, + translation_key="could_not_change_alarm", ) @property diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 50d7b28c52b4bc..c5a9bb79ba8491 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -79,14 +79,24 @@ async def async_set_lock(self, command: str, code: str | None) -> None: ) except YALE_ALL_ERRORS as error: raise HomeAssistantError( - f"Could not set lock for {self.lock_name}: {error}" + f"Could not set lock for {self.lock_name}: {error}", + translation_domain=DOMAIN, + translation_key="set_lock", + translation_placeholders={ + "name": self.lock_name, + "error": str(error), + }, ) from error if lock_state: self.coordinator.data["lock_map"][self._attr_unique_id] = command self.async_write_ha_state() return - raise HomeAssistantError("Could not set lock, check system ready for lock.") + raise HomeAssistantError( + "Could not set lock, check system ready for lock", + translation_domain=DOMAIN, + translation_key="could_not_change_lock", + ) @property def is_locked(self) -> bool | None: diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index a51d151d7d9248..a698da20d8d147 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -56,5 +56,19 @@ "name": "Panic button" } } + }, + "exceptions": { + "set_alarm": { + "message": "Could not set alarm for {name}: {error}" + }, + "could_not_change_alarm": { + "message": "Could not change alarm, check system ready for arming" + }, + "set_lock": { + "message": "Could not set lock for {name}: {error}" + }, + "could_not_change_lock": { + "message": "Could not set lock, check system ready for lock" + } } } diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 11516015b6c0b2..b5683777c24ad5 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -10,6 +10,7 @@ LockState, PushLock, YaleXSBLEError, + close_stale_connections_by_address, local_name_is_unique, ) @@ -47,6 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: id_ = local_name if has_unique_local_name else address push_lock.set_name(f"{entry.title} ({id_})") + # Ensure any lingering connections are closed since the device may not be + # advertising when its connected to another client which will prevent us + # from setting the device and setup will fail. + await close_stale_connections_by_address(address) + @callback def _async_update_ble( service_info: bluetooth.BluetoothServiceInfoBleak, diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index d457784a038b4b..f6fa1917d7e37f 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -40,17 +40,19 @@ def _async_update_state( self._attr_is_unlocking = False self._attr_is_jammed = False lock_state = new_state.lock - if lock_state == LockStatus.LOCKED: + if lock_state is LockStatus.LOCKED: self._attr_is_locked = True - elif lock_state == LockStatus.LOCKING: + elif lock_state is LockStatus.LOCKING: self._attr_is_locking = True - elif lock_state == LockStatus.UNLOCKING: + elif lock_state is LockStatus.UNLOCKING: self._attr_is_unlocking = True elif lock_state in ( LockStatus.UNKNOWN_01, LockStatus.UNKNOWN_06, ): self._attr_is_jammed = True + elif lock_state is LockStatus.UNKNOWN: + self._attr_is_locked = None super()._async_update_state(new_state, lock_info, connection_info) async def async_unlock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index be388ec563c5e2..dcd7e57ce1fc11 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.3.2"] + "requirements": ["yalexs-ble==2.4.0"] } diff --git a/homeassistant/components/yalexs_ble/sensor.py b/homeassistant/components/yalexs_ble/sensor.py index 9d702ff52eb642..da698d1b501d07 100644 --- a/homeassistant/components/yalexs_ble/sensor.py +++ b/homeassistant/components/yalexs_ble/sensor.py @@ -27,14 +27,14 @@ from .models import YaleXSBLEData -@dataclass +@dataclass(frozen=True) class YaleXSBLERequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[LockState, LockInfo, ConnectionInfo], int | float | None] -@dataclass +@dataclass(frozen=True) class YaleXSBLESensorEntityDescription( SensorEntityDescription, YaleXSBLERequiredKeysMixin ): diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index 94153a47fdc85a..b64f5aba6b7918 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -95,9 +95,7 @@ async def async_step_ssdp( 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 c4f28fc750bf9f..d0ee6c030a6915 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 f841f3d3ed1d59..fcaef65ee3ef44 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 6c44736fa6db28..4881d8c576d4b7 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.38.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index ab22f42dae3883..72baec52c85875 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/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index e65896cdd42008..0650cc3a20317b 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -28,7 +28,7 @@ from .entity import YoLinkEntity -@dataclass +@dataclass(frozen=True) class YoLinkBinarySensorEntityDescription(BinarySensorEntityDescription): """YoLink BinarySensorEntityDescription.""" diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 7322c58ae04fbe..a42687a3551197 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.3.1"] + "requirements": ["yolink-api==0.3.4"] } diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 2fc4a2b07251f7..ace133533411d6 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -48,21 +48,13 @@ from .entity import YoLinkEntity -@dataclass -class YoLinkSensorEntityDescriptionMixin: - """Mixin for device type.""" - - exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True - - -@dataclass -class YoLinkSensorEntityDescription( - YoLinkSensorEntityDescriptionMixin, SensorEntityDescription -): +@dataclass(frozen=True, kw_only=True) +class YoLinkSensorEntityDescription(SensorEntityDescription): """YoLink SensorEntityDescription.""" - value: Callable = lambda state: state + exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True should_update_entity: Callable = lambda state: True + value: Callable = lambda state: state SENSOR_DEVICE_TYPE = [ diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index 81c2b46a840cec..4a35e9506e9897 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -23,7 +23,7 @@ from .entity import YoLinkEntity -@dataclass +@dataclass(frozen=True) class YoLinkSirenEntityDescription(SirenEntityDescription): """YoLink SirenEntityDescription.""" diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 07df1008653e78..212d7ced7d76dc 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -18,8 +18,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "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]" + "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/yolink/switch.py b/homeassistant/components/yolink/switch.py index 018fcb84988cca..69a958ba6d1d93 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -29,7 +29,7 @@ from .entity import YoLinkEntity -@dataclass +@dataclass(frozen=True) class YoLinkSwitchEntityDescription(SwitchEntityDescription): """YoLink SwitchEntityDescription.""" diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index 563e6834ddd29b..e0eddd7d137810 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/const.py b/homeassistant/components/youtube/const.py index 7404cd046652b5..63c4480c007dfa 100644 --- a/homeassistant/components/youtube/const.py +++ b/homeassistant/components/youtube/const.py @@ -7,7 +7,6 @@ CHANNEL_CREATION_HELP_URL = "https://support.google.com/youtube/answer/1646861" CONF_CHANNELS = "channels" -CONF_ID = "id" CONF_UPLOAD_PLAYLIST = "upload_playlist_id" COORDINATOR = "coordinator" AUTH = "auth" diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 99cd3ecf095836..d037a8c3c4b5b3 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -26,7 +26,7 @@ from .entity import YouTubeChannelEntity -@dataclass +@dataclass(frozen=True) class YouTubeMixin: """Mixin for required keys.""" @@ -36,7 +36,7 @@ class YouTubeMixin: attributes_fn: Callable[[Any], dict[str, Any] | None] | None -@dataclass +@dataclass(frozen=True) class YouTubeSensorEntityDescription(SensorEntityDescription, YouTubeMixin): """Describes YouTube sensor entity.""" diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index 0bd62a42314615..d664e2f15e7842 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -9,8 +9,8 @@ "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]" + "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%]", diff --git a/homeassistant/components/zamg/const.py b/homeassistant/components/zamg/const.py index e1733600f59795..ea1e91d61496d4 100644 --- a/homeassistant/components/zamg/const.py +++ b/homeassistant/components/zamg/const.py @@ -14,13 +14,11 @@ ATTR_STATION = "station" ATTR_UPDATED = "updated" -ATTRIBUTION = "Data provided by ZAMG" +ATTRIBUTION = "Data provided by GeoSphere Austria" CONF_STATION_ID = "station_id" -DEFAULT_NAME = "zamg" - -MANUFACTURER_URL = "https://www.zamg.ac.at" +MANUFACTURER_URL = "https://www.geosphere.at" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index df17672231e15f..e7fe584c767c8c 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -1,9 +1,9 @@ { "domain": "zamg", - "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)", + "name": "GeoSphere Austria", "codeowners": ["@killer0071234"], "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/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 31275dd908dd44..adc07212a5f047 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -37,14 +37,14 @@ from .coordinator import ZamgDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class ZamgRequiredKeysMixin: """Mixin for required keys.""" para_name: str -@dataclass +@dataclass(frozen=True) class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin): """Describes Zamg sensor entity.""" @@ -202,7 +202,7 @@ def __init__( identifiers={(DOMAIN, station_id)}, manufacturer=ATTRIBUTION, configuration_url=MANUFACTURER_URL, - name=coordinator.name, + name=name, ) coordinator.api_fields = API_FIELDS diff --git a/homeassistant/components/zamg/strings.json b/homeassistant/components/zamg/strings.json index a92e7aa605e2b0..6ffc489bdf56b8 100644 --- a/homeassistant/components/zamg/strings.json +++ b/homeassistant/components/zamg/strings.json @@ -3,7 +3,7 @@ "flow_title": "{name}", "step": { "user": { - "description": "Set up ZAMG to integrate with Home Assistant.", + "description": "Set up GeoSphere Austria to integrate with Home Assistant.", "data": { "station_id": "Station ID (Defaults to nearest station)" } @@ -11,7 +11,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "station_not_found": "Station ID not found at zamg" + "station_not_found": "Station ID not found at GeoSphere Austria" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 98e08106dca11c..e855bde29d844f 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -43,14 +43,14 @@ def __init__( """Initialise the platform with a data instance and station name.""" super().__init__(coordinator) self._attr_unique_id = station_id - self._attr_name = f"ZAMG {name}" + self._attr_name = name self.station_id = f"{station_id}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, station_id)}, manufacturer=ATTRIBUTION, configuration_url=MANUFACTURER_URL, - name=coordinator.name, + name=name, ) @property diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index bf0984d3989ef0..e12a7599d4dc76 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -20,7 +20,7 @@ IPVersion, ServiceStateChange, ) -from zeroconf.asyncio import AsyncServiceInfo +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo from homeassistant import config_entries from homeassistant.components import network @@ -33,13 +33,14 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import ( HomeKitDiscoveredIntegration, + ZeroconfMatcher, async_get_homekit, async_get_zeroconf, bind_hass, ) from homeassistant.setup import async_when_setup_or_start -from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf +from .models import HaAsyncZeroconf, HaZeroconf from .usage import install_multiple_zeroconf_catcher _LOGGER = logging.getLogger(__name__) @@ -54,9 +55,6 @@ ] _HOMEKIT_MODEL_SPLITS = (None, " ", "-") -# Top level keys we support matching against in properties that are always matched in -# lower case. ex: ZeroconfServiceInfo.name -LOWER_MATCH_ATTRS = {"name"} CONF_DEFAULT_INTERFACE = "default_interface" CONF_IPV6 = "ipv6" @@ -74,6 +72,8 @@ # Dns label max length MAX_NAME_LEN = 63 +ATTR_DOMAIN: Final = "domain" +ATTR_NAME: Final = "name" ATTR_PROPERTIES: Final = "properties" # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] @@ -128,12 +128,12 @@ class ZeroconfServiceInfo(BaseServiceInfo): @property def host(self) -> str: """Return the host.""" - return _stringify_ip_address(self.ip_address) + return str(self.ip_address) @property def addresses(self) -> list[str]: """Return the addresses.""" - return [_stringify_ip_address(ip_address) for ip_address in self.ip_addresses] + return [str(ip_address) for ip_address in self.ip_addresses] @bind_hass @@ -227,7 +227,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zeroconf_types, homekit_model_lookup, homekit_model_matchers, - ipv6, ) await discovery.async_setup() @@ -249,7 +248,7 @@ async def _async_zeroconf_hass_stop(_event: Event) -> None: def _build_homekit_model_lookups( - homekit_models: dict[str, HomeKitDiscoveredIntegration] + homekit_models: dict[str, HomeKitDiscoveredIntegration], ) -> tuple[ dict[str, HomeKitDiscoveredIntegration], dict[re.Pattern, HomeKitDiscoveredIntegration], @@ -320,30 +319,13 @@ async def _async_register_hass_zc_service( await aio_zc.async_register_service(info, allow_name_change=True) -def _match_against_data( - matcher: dict[str, str | dict[str, str]], match_data: dict[str, str] -) -> bool: - """Check a matcher to ensure all values in match_data match.""" - for key in LOWER_MATCH_ATTRS: - if key not in matcher: - continue - if key not in match_data: - return False - match_val = matcher[key] - if TYPE_CHECKING: - assert isinstance(match_val, str) - - if not _memorized_fnmatch(match_data[key], match_val): - return False - return True - - -def _match_against_props(matcher: dict[str, str], props: dict[str, str]) -> bool: +def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: """Check a matcher to ensure all values in props.""" return not any( key for key in matcher - if key not in props or not _memorized_fnmatch(props[key].lower(), matcher[key]) + if key not in props + or not _memorized_fnmatch((props[key] or "").lower(), matcher[key]) ) @@ -365,10 +347,9 @@ def __init__( self, hass: HomeAssistant, zeroconf: HaZeroconf, - zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]], + zeroconf_types: dict[str, list[ZeroconfMatcher]], homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - ipv6: bool, ) -> None: """Init discovery.""" self.hass = hass @@ -376,10 +357,7 @@ def __init__( self.zeroconf_types = zeroconf_types self.homekit_model_lookups = homekit_model_lookups self.homekit_model_matchers = homekit_model_matchers - - self.ipv6 = ipv6 - - self.async_service_browser: HaAsyncServiceBrowser | None = None + self.async_service_browser: AsyncServiceBrowser | None = None async def async_setup(self) -> None: """Start discovery.""" @@ -391,8 +369,8 @@ async def async_setup(self) -> None: if hk_type not in self.zeroconf_types: types.append(hk_type) _LOGGER.debug("Starting Zeroconf browser for: %s", types) - self.async_service_browser = HaAsyncServiceBrowser( - self.ipv6, self.zeroconf, types, handlers=[self.async_service_update] + self.async_service_browser = AsyncServiceBrowser( + self.zeroconf, types, handlers=[self.async_service_update] ) async def async_stop(self) -> None: @@ -467,7 +445,7 @@ def _async_process_service_update( _LOGGER.debug("Failed to get addresses for device %s", name) return _LOGGER.debug("Discovered new device %s %s", name, info) - props: dict[str, str] = info.properties + props: dict[str, str | None] = info.properties domain = None # If we can handle it as a HomeKit discovery, we do that here. @@ -500,27 +478,23 @@ def _async_process_service_update( # discover it, we can stop here. return - match_data: dict[str, str] = {} - for key in LOWER_MATCH_ATTRS: - attr_value: str = getattr(info, key) - match_data[key] = attr_value.lower() + if not (matchers := self.zeroconf_types.get(service_type)): + return # Not all homekit types are currently used for discovery # so not all service type exist in zeroconf_types - for matcher in self.zeroconf_types.get(service_type, []): + for matcher in matchers: if len(matcher) > 1: - if not _match_against_data(matcher, match_data): + if ATTR_NAME in matcher and not _memorized_fnmatch( + info.name.lower(), matcher[ATTR_NAME] + ): + continue + if ATTR_PROPERTIES in matcher and not _match_against_props( + matcher[ATTR_PROPERTIES], props + ): continue - if ATTR_PROPERTIES in matcher: - matcher_props = matcher[ATTR_PROPERTIES] - if TYPE_CHECKING: - assert isinstance(matcher_props, dict) - if not _match_against_props(matcher_props, props): - continue - - matcher_domain = matcher["domain"] - if TYPE_CHECKING: - assert isinstance(matcher_domain, str) + + matcher_domain = matcher[ATTR_DOMAIN] context = { "source": config_entries.SOURCE_ZEROCONF, } @@ -563,10 +537,6 @@ def async_get_homekit_discovery( return None -# matches to the cache in zeroconf itself -_stringify_ip_address = lru_cache(maxsize=256)(str) - - def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" # See https://ietf.org/rfc/rfc6763.html#section-6.4 and @@ -586,19 +556,10 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: if not ip_address: return None - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], service.properties) - - properties: dict[str, Any] = { - k.decode("ascii", "replace"): None - if v is None - else v.decode("utf-8", "replace") - for k, v in service_properties.items() - } - - assert service.server is not None, "server cannot be none if there are addresses" + if TYPE_CHECKING: + assert ( + service.server is not None + ), "server cannot be none if there are addresses" return ZeroconfServiceInfo( ip_address=ip_address, ip_addresses=ip_addresses, @@ -606,7 +567,7 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: hostname=service.server, type=service.type, name=service.name, - properties=properties, + properties=service.decoded_properties, ) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 00f81be0793008..aecc88968f301a 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.123.0"] + "requirements": ["zeroconf==0.131.0"] } diff --git a/homeassistant/components/zeroconf/models.py b/homeassistant/components/zeroconf/models.py index ffa5e1a2ecf713..7393e699b51341 100644 --- a/homeassistant/components/zeroconf/models.py +++ b/homeassistant/components/zeroconf/models.py @@ -1,11 +1,7 @@ """Models for Zeroconf.""" -from typing import Any - -from zeroconf import DNSAddress, DNSRecord, Zeroconf -from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf - -TYPE_AAAA = 28 +from zeroconf import Zeroconf +from zeroconf.asyncio import AsyncZeroconf class HaZeroconf(Zeroconf): @@ -24,22 +20,3 @@ async def async_close(self) -> None: """Fake method to avoid integrations closing it.""" ha_async_close = AsyncZeroconf.async_close - - -class HaAsyncServiceBrowser(AsyncServiceBrowser): - """ServiceBrowser that only consumes DNSPointer records.""" - - def __init__(self, ipv6: bool, *args: Any, **kwargs: Any) -> None: - """Create service browser that filters ipv6 if it is disabled.""" - self.ipv6 = ipv6 - super().__init__(*args, **kwargs) - - def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None: - """Pre-Filter AAAA records if IPv6 is not enabled.""" - if ( - not self.ipv6 - and isinstance(record, DNSAddress) - and record.type == TYPE_AAAA - ): - return - super().update_record(zc, now, record) diff --git a/homeassistant/components/zeversolar/sensor.py b/homeassistant/components/zeversolar/sensor.py index ee9aa5531c83d1..9e2333a1e24b0c 100644 --- a/homeassistant/components/zeversolar/sensor.py +++ b/homeassistant/components/zeversolar/sensor.py @@ -22,14 +22,14 @@ from .entity import ZeversolarEntity -@dataclass +@dataclass(frozen=True) class ZeversolarEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[zeversolar.ZeverSolarData], zeversolar.kWh | zeversolar.Watt] -@dataclass +@dataclass(frozen=True) class ZeversolarEntityDescription( SensorEntityDescription, ZeversolarEntityDescriptionMixin ): diff --git a/homeassistant/components/zeversolar/strings.json b/homeassistant/components/zeversolar/strings.json index 0e2e23f244c1f8..b75bbe781ef133 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 222c7f1d4ef46a..1eb3369c1bef9c 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -9,12 +9,12 @@ 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 @@ CONF_CUSTOM_QUIRKS_PATH, CONF_DEVICE_CONFIG, CONF_ENABLE_QUIRKS, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, CONF_USB_PATH, CONF_ZIGPY, @@ -158,10 +159,12 @@ 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) - try: - await zha_gateway.async_initialize() + zha_gateway = await ZHAGateway.async_from_config( + hass=hass, + config=zha_data.yaml_config, + config_entry=config_entry, + ) except NetworkSettingsInconsistent as exc: await warn_on_inconsistent_network_settings( hass, @@ -169,31 +172,45 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b old_state=exc.old_state, new_state=exc.new_state, ) - raise HomeAssistantError( + raise ConfigEntryError( "Network settings do not match most recent backup" ) from exc - except Exception: - if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: + except TransientConnectionError as exc: + raise ConfigEntryNotReady from exc + except Exception as exc: + _LOGGER.debug("Failed to set up ZHA", exc_info=exc) + device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + + if ( + not device_path.startswith("socket://") + and 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 + # Ignore all exceptions during probing, they shouldn't halt setup + if await warn_on_wrong_silabs_firmware(hass, device_path): + raise ConfigEntryError("Incorrect firmware installed") from exc + except AlreadyRunningEZSP as ezsp_exc: + raise ConfigEntryNotReady from ezsp_exc - raise + raise ConfigEntryNotReady from exc 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 +284,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 1b6bbee5159deb..60cf917d9f63ed 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -27,12 +27,13 @@ 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 @@ CONF_MANUAL_PATH = "Enter Manually" SUPPORTED_PORT_SETTINGS = ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, ) DECONZ_DOMAIN = "deconz" @@ -160,7 +161,7 @@ async def _async_create_radio_entry(self) -> FlowResult: 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 @@ async def async_step_manual_port_config( 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 @@ async def async_step_maybe_confirm_ezsp_restore( 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 @@ async def async_step_hardware( 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 980a6f88a757b5..16c7aef89adfb5 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 ..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 6ca4e420d5f9e0..8bc6902b4ff054 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -4,6 +4,7 @@ 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 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: 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/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index dad3ee5eb4d4e7..5e41785a6d8c28 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -110,6 +110,7 @@ class ThermostatClusterHandler(ClusterHandler): "max_heat_setpoint_limit": True, "min_cool_setpoint_limit": True, "min_heat_setpoint_limit": True, + "local_temperature_calibration": True, } @property diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index f2e5dafa099b56..57f1e2ee30462f 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -5,7 +5,9 @@ from typing import TYPE_CHECKING, Any from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType +from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, XIAOMI_AQARA_VIBRATION_AQ1 import zigpy.zcl +from zigpy.zcl.clusters.closures import DoorLock from homeassistant.core import callback @@ -23,6 +25,7 @@ UNKNOWN, ) from . import AttrReportConfig, ClientClusterHandler, ClusterHandler +from .general import MultistateInput if TYPE_CHECKING: from ..endpoint import Endpoint @@ -72,25 +75,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, @@ -164,6 +149,17 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: "buzzer": True, "linkage_alarm": True, } + elif self.cluster.endpoint.model == "lumi.magnet.ac01": + self.ZCL_INIT_ATTRS = { + "detection_distance": True, + } + elif self.cluster.endpoint.model == "lumi.switch.acn047": + self.ZCL_INIT_ATTRS = { + "switch_mode": True, + "switch_type": True, + "startup_on_off": True, + "decoupled_mode": True, + } async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: """Initialize cluster handler specific.""" @@ -241,49 +237,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, @@ -375,3 +416,10 @@ class IkeaRemote(ClusterHandler): """Ikea Matter remote cluster handler.""" REPORT_CONFIG = () + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + DoorLock.cluster_id, XIAOMI_AQARA_VIBRATION_AQ1 +) +class XiaomiVibrationAQ1ClusterHandler(MultistateInput): + """Xiaomi DoorLock Cluster is in fact a MultiStateInput Cluster.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 8fd38425dff3a7..2ceaeaf1013341 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -195,9 +195,9 @@ async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> N ) # 1 digit to the right, 15 digits to the left self._summa_format = self.get_formatting(fmting) - async def async_force_update(self) -> None: + async def async_update(self) -> None: """Retrieve latest state.""" - self.debug("async_force_update") + self.debug("async_update") attrs = [ a["attr"] diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 9874fddc5982e7..ecbd347a6211c3 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -127,6 +127,7 @@ 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_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" @@ -409,9 +409,6 @@ class Strobe(t.enum8): Strobe = 0x01 -STARTUP_FAILURE_DELAY_S = 3 -STARTUP_RETRIES = 3 - EZSP_OVERWRITE_EUI64 = ( "i_understand_i_can_update_eui64_only_once_and_i_still_want_to_do_it" ) diff --git a/homeassistant/components/zha/core/decorators.py b/homeassistant/components/zha/core/decorators.py index 71bfd510bea09e..192f68489895ef 100644 --- a/homeassistant/components/zha/core/decorators.py +++ b/homeassistant/components/zha/core/decorators.py @@ -21,6 +21,24 @@ def decorator(cluster_handler: _TypeT) -> _TypeT: return decorator +class NestedDictRegistry(dict[int | str, dict[int | str | None, _TypeT]]): + """Dict Registry of multiple items per key.""" + + def register( + self, name: int | str, sub_name: int | str | None = None + ) -> Callable[[_TypeT], _TypeT]: + """Return decorator to register item with a specific and a quirk name.""" + + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + if name not in self: + self[name] = {} + self[name][sub_name] = cluster_handler + return cluster_handler + + return decorator + + class SetRegistry(set[int | str]): """Set Registry of items.""" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 44acbb172fcce7..1a3d3a2da1f815 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -166,6 +166,9 @@ def __init__( if not self.is_coordinator: keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) + self.debug( + "starting availability checks - interval: %s", keep_alive_interval + ) self.unsubs.append( async_track_time_interval( self.hass, @@ -285,7 +288,7 @@ def is_active_coordinator(self) -> bool: 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: @@ -447,35 +450,36 @@ async def _check_available(self, *_: Any) -> None: self._checkins_missed_count = 0 return - if ( - self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS - or self.manufacturer == "LUMI" - or not self._endpoints - ): + if self.hass.data[const.DATA_ZHA].allow_polling: + if ( + self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS + or self.manufacturer == "LUMI" + or not self._endpoints + ): + self.debug( + ( + "last_seen is %s seconds ago and ping attempts have been exhausted," + " marking the device unavailable" + ), + difference, + ) + self.update_available(False) + return + + self._checkins_missed_count += 1 self.debug( - ( - "last_seen is %s seconds ago and ping attempts have been exhausted," - " marking the device unavailable" - ), - difference, + "Attempting to checkin with device - missed checkins: %s", + self._checkins_missed_count, ) - self.update_available(False) - return - - self._checkins_missed_count += 1 - self.debug( - "Attempting to checkin with device - missed checkins: %s", - self._checkins_missed_count, - ) - if not self.basic_ch: - self.debug("does not have a mandatory basic cluster") - self.update_available(False) - return - res = await self.basic_ch.get_attribute_value( - ATTR_MANUFACTURER, from_cache=False - ) - if res is not None: - self._checkins_missed_count = 0 + if not self.basic_ch: + self.debug("does not have a mandatory basic cluster") + self.update_available(False) + return + res = await self.basic_ch.get_attribute_value( + ATTR_MANUFACTURER, from_cache=False + ) + if res is not None: + self._checkins_missed_count = 0 def update_available(self, available: bool) -> None: """Update device availability and signal entities.""" diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 90ed68f9b00da4..1944f632e9a699 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -203,9 +203,20 @@ def handle_on_off_output_cluster_exception(self, endpoint: Endpoint) -> None: if platform is None: continue - cluster_handler_class = zha_regs.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, ClusterHandler + cluster_handler_classes = zha_regs.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, {None: ClusterHandler} ) + + quirk_id = ( + endpoint.device.quirk_id + if endpoint.device.quirk_id in cluster_handler_classes + else None + ) + + cluster_handler_class = cluster_handler_classes.get( + quirk_id, ClusterHandler + ) + cluster_handler = cluster_handler_class(cluster, endpoint) self.probe_single_cluster(platform, cluster_handler, endpoint) diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index c87ee60d6b30d6..04c253128ee5b1 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -6,7 +6,6 @@ import logging from typing import TYPE_CHECKING, Any, Final, TypeVar -import zigpy from zigpy.typing import EndpointType as ZigpyEndpointType from homeassistant.const import Platform @@ -15,7 +14,6 @@ from . import const, discovery, registries from .cluster_handlers import ClusterHandler -from .cluster_handlers.general import MultistateInput from .helpers import get_zha_data if TYPE_CHECKING: @@ -116,8 +114,16 @@ def new(cls, zigpy_endpoint: ZigpyEndpointType, device: ZHADevice) -> Endpoint: def add_all_cluster_handlers(self) -> None: """Create and add cluster handlers for all input clusters.""" for cluster_id, cluster in self.zigpy_endpoint.in_clusters.items(): - cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, ClusterHandler + cluster_handler_classes = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, {None: ClusterHandler} + ) + quirk_id = ( + self.device.quirk_id + if self.device.quirk_id in cluster_handler_classes + else None + ) + cluster_handler_class = cluster_handler_classes.get( + quirk_id, ClusterHandler ) # Allow cluster handler to filter out bad matches @@ -129,15 +135,6 @@ def add_all_cluster_handlers(self) -> None: cluster_id, cluster_handler_class, ) - # really ugly hack to deal with xiaomi using the door lock cluster - # incorrectly. - if ( - hasattr(cluster, "ep_attribute") - and cluster_id == zigpy.zcl.clusters.closures.DoorLock.cluster_id - and cluster.ep_attribute == "multistate_input" - ): - cluster_handler_class = MultistateInput - # end of ugly hack try: cluster_handler = cluster_handler_class(cluster, self) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index b4c02d330151c2..12e439f1059a77 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -11,7 +11,7 @@ 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 @@ ) 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 @@ -49,6 +48,7 @@ CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, + DATA_ZHA, DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, @@ -66,8 +66,6 @@ SIGNAL_ADD_ENTITIES, SIGNAL_GROUP_MEMBERSHIP_CHANGE, SIGNAL_REMOVE, - STARTUP_FAILURE_DELAY_S, - STARTUP_RETRIES, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, ZHA_GW_MSG, @@ -123,10 +121,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 +129,8 @@ def __init__( 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 +142,11 @@ def __init__( 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 +163,7 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: # 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 +182,40 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: ): 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 +229,17 @@ async def async_initialize(self) -> None: 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 +254,7 @@ def _find_coordinator_device(self) -> zigpy.device.Device: @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 +277,7 @@ def async_load_devices(self) -> None: @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) @@ -305,6 +303,10 @@ async def fetch_updated_state() -> None: if dev.is_mains_powered ) ) + _LOGGER.debug( + "completed fetching current state for mains powered devices - allowing polled requests" + ) + self.hass.data[DATA_ZHA].allow_polling = True # background the fetching of state for mains powered devices self.config_entry.async_create_background_task( @@ -521,9 +523,9 @@ def _cleanup_group_entity_registry_entries( 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 +713,7 @@ async def async_create_zigpy_group( 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 +761,13 @@ async def async_remove_zigpy_group(self, group_id: int) -> None: 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/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 0246c1e4b1c59b..bb87cb2cf58f12 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -442,6 +442,7 @@ class ZHAData: device_trigger_cache: dict[str, tuple[str, dict]] = dataclasses.field( default_factory=dict ) + allow_polling: bool = dataclasses.field(default=False) def get_zha_data(hass: HomeAssistant) -> ZHAData: diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 4bdedebfff9ff0..b302116694d67c 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -15,7 +15,7 @@ from homeassistant.const import Platform -from .decorators import DictRegistry, SetRegistry +from .decorators import DictRegistry, NestedDictRegistry, SetRegistry if TYPE_CHECKING: from ..entity import ZhaEntity, ZhaGroupEntity @@ -110,7 +110,9 @@ CLIENT_CLUSTER_HANDLER_REGISTRY: DictRegistry[ type[ClientClusterHandler] ] = DictRegistry() -ZIGBEE_CLUSTER_HANDLER_REGISTRY: DictRegistry[type[ClusterHandler]] = DictRegistry() +ZIGBEE_CLUSTER_HANDLER_REGISTRY: NestedDictRegistry[ + type[ClusterHandler] +] = NestedDictRegistry() WEIGHT_ATTR = attrgetter("weight") @@ -253,7 +255,7 @@ def _matched( else: matches.append(model in self.models) - if self.quirk_ids and quirk_id: + if self.quirk_ids: if callable(self.quirk_ids): matches.append(self.quirk_ids(quirk_id)) else: diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 05e1da7c570e79..b92d077907fd49 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -92,7 +92,7 @@ def device_info(self) -> DeviceInfo: 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 05bf3469c7b8fd..7364aed0d1b19d 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -13,7 +13,6 @@ ATTR_PRESET_MODE, FanEntity, FanEntityFeature, - NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, Platform @@ -21,10 +20,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .core import discovery from .core.cluster_handlers import wrap_zigpy_exceptions @@ -131,11 +130,6 @@ async def async_set_percentage(self, percentage: int) -> None: 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 6a01d550466794..486b043b4505cc 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -47,6 +47,7 @@ CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, CONF_GROUP_MEMBERS_ASSUME_STATE, + DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, @@ -75,7 +76,6 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.LIGHT) GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT) -PARALLEL_UPDATES = 0 SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" SIGNAL_LIGHT_GROUP_TRANSITION_START = "zha_light_group_transition_start" SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED = "zha_light_group_transition_finished" @@ -788,6 +788,7 @@ async def async_added_to_hass(self) -> None: self._cancel_refresh_handle = async_track_time_interval( self.hass, self._refresh, timedelta(seconds=refresh_interval) ) + self.debug("started polling with refresh interval of %s", refresh_interval) self.async_accept_signal( None, SIGNAL_LIGHT_GROUP_STATE_CHANGED, @@ -838,6 +839,8 @@ async def async_will_remove_from_hass(self) -> None: """Disconnect entity object when removed.""" assert self._cancel_refresh_handle self._cancel_refresh_handle() + self._cancel_refresh_handle = None + self.debug("stopped polling during device removal") await super().async_will_remove_from_hass() @callback @@ -980,8 +983,16 @@ async def _refresh(self, time): if self.is_transitioning: self.debug("skipping _refresh while transitioning") return - await self.async_get_state() - self.async_write_ha_state() + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("polling for updated state") + await self.async_get_state() + self.async_write_ha_state() + else: + self.debug( + "skipping polling for updated state, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) async def _maybe_force_refresh(self, signal): """Force update the state if the signal contains the entity id for this entity.""" @@ -989,8 +1000,16 @@ async def _maybe_force_refresh(self, signal): if self.is_transitioning: self.debug("skipping _maybe_force_refresh while transitioning") return - await self.async_get_state() - self.async_write_ha_state() + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("forcing polling for updated state") + await self.async_get_state() + self.async_write_ha_state() + else: + self.debug( + "skipping _maybe_force_refresh, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) @callback def _assume_group_state(self, signal, update_params) -> None: @@ -1072,7 +1091,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 af2c8405e5fe44..06ebfaaa6a0622 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,16 +21,16 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.8", + "bellows==0.37.6", "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.109", + "zigpy-deconz==0.22.4", + "zigpy==0.60.4", + "zigpy-xbee==0.20.1", + "zigpy-zigate==0.12.0", + "zigpy-znp==0.12.1", + "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 ae2f9e0b758799..24964d7a154021 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -20,6 +20,7 @@ CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_THERMOSTAT, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -629,7 +630,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 +779,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): @@ -931,3 +948,21 @@ class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): _attr_mode: NumberMode = NumberMode.SLIDER _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS _attr_icon: str = ICONS[0] + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ThermostatLocalTempCalibration(ZHANumberConfigurationEntity): + """Local temperature calibration.""" + + _unique_id_suffix = "local_temperature_calibration" + _attr_native_min_value: float = -2.5 + _attr_native_max_value: float = 2.5 + _attr_native_step: float = 0.1 + _attr_multiplier: float = 0.1 + _attribute_name = "local_temperature_calibration" + _attr_translation_key: str = "local_temperature_calibration" + + _attr_mode: NumberMode = NumberMode.SLIDER + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = ICONS[0] diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index d20cf752a918b5..d3ca03de8d89c4 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -19,6 +19,7 @@ CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_BACKUP_ENABLED, + SCHEMA_DEVICE, ) from zigpy.exceptions import NetworkNotFormed @@ -58,10 +59,21 @@ 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 @@ async def detect_radio_type(self) -> ProbeResult: 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 @@ async def async_initiate_migration(self, data: dict[str, Any]) -> bool: 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 46089dd5a288fd..1c13779209db7b 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -6,6 +6,9 @@ import logging from typing import TYPE_CHECKING, Any, Self +from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF +from zhaquirks.xiaomi.aqara.magnet_ac01 import OppleCluster as MagnetAC01OppleCluster +from zhaquirks.xiaomi.aqara.switch_acn047 import OppleCluster as T2RelayOppleCluster from zigpy import types from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasWd @@ -246,29 +249,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 +272,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 +293,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.""" @@ -445,6 +410,66 @@ class AqaraApproachDistance(ZCLEnumSelectEntity): _attr_translation_key: str = "approach_distance" +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.magnet.ac01"} +) +class AqaraMagnetAC01DetectionDistance(ZCLEnumSelectEntity): + """Representation of a ZHA detection distance configuration entity.""" + + _unique_id_suffix = "detection_distance" + _attribute_name = "detection_distance" + _enum = MagnetAC01OppleCluster.DetectionDistance + _attr_translation_key: str = "detection_distance" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelaySwitchMode(ZCLEnumSelectEntity): + """Representation of a ZHA switch mode configuration entity.""" + + _unique_id_suffix = "switch_mode" + _attribute_name = "switch_mode" + _enum = T2RelayOppleCluster.SwitchMode + _attr_translation_key: str = "switch_mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelaySwitchType(ZCLEnumSelectEntity): + """Representation of a ZHA switch type configuration entity.""" + + _unique_id_suffix = "switch_type" + _attribute_name = "switch_type" + _enum = T2RelayOppleCluster.SwitchType + _attr_translation_key: str = "switch_type" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelayStartupOnOff(ZCLEnumSelectEntity): + """Representation of a ZHA startup on off configuration entity.""" + + _unique_id_suffix = "startup_on_off" + _attribute_name = "startup_on_off" + _enum = T2RelayOppleCluster.StartupOnOff + _attr_translation_key: str = "start_up_on_off" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelayDecoupledMode(ZCLEnumSelectEntity): + """Representation of a ZHA switch decoupled mode configuration entity.""" + + _unique_id_suffix = "decoupled_mode" + _attribute_name = "decoupled_mode" + _enum = T2RelayOppleCluster.DecoupledMode + _attr_translation_key: str = "decoupled_mode" + + class AqaraE1ReverseDirection(types.enum8): """Aqara curtain reversal.""" @@ -484,7 +509,7 @@ class InovelliOutputModeEntity(ZCLEnumSelectEntity): class InovelliSwitchType(types.enum8): - """Inovelli output mode.""" + """Inovelli switch mode.""" Single_Pole = 0x00 Three_Way_Dumb = 0x01 @@ -493,7 +518,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 +529,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 +567,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/sensor.py b/homeassistant/components/zha/sensor.py index 4fe96109c4625d..027e710e30c762 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -1,9 +1,11 @@ """Sensors on Zigbee Home Automation networks.""" from __future__ import annotations +from datetime import timedelta import enum import functools import numbers +import random from typing import TYPE_CHECKING, Any, Self from zigpy import types @@ -37,9 +39,10 @@ UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import StateType from .core import discovery @@ -57,6 +60,7 @@ CLUSTER_HANDLER_SOIL_MOISTURE, CLUSTER_HANDLER_TEMPERATURE, CLUSTER_HANDLER_THERMOSTAT, + DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -68,8 +72,6 @@ from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice -PARALLEL_UPDATES = 5 - BATTERY_SIZES = { 0: "No battery", 1: "Built in", @@ -185,6 +187,55 @@ def formatter(self, value: int | enum.IntEnum) -> int | float | str | None: return round(float(value * self._multiplier) / self._divisor) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PollableSensor(Sensor): + """Base ZHA sensor that polls for state.""" + + _use_custom_polling: bool = True + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this sensor.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cancel_refresh_handle: CALLBACK_TYPE | None = None + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + if self._use_custom_polling: + refresh_interval = random.randint(30, 60) + self._cancel_refresh_handle = async_track_time_interval( + self.hass, self._refresh, timedelta(seconds=refresh_interval) + ) + self.debug("started polling with refresh interval of %s", refresh_interval) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + assert self._cancel_refresh_handle + self._cancel_refresh_handle() + self._cancel_refresh_handle = None + self.debug("stopped polling during device removal") + await super().async_will_remove_from_hass() + + async def _refresh(self, time): + """Call async_update at a constrained random interval.""" + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("polling for updated state") + await self.async_update() + self.async_write_ha_state() + else: + self.debug( + "skipping polling for updated state, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) + + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_ANALOG_INPUT, manufacturers="Digi", @@ -231,7 +282,7 @@ def create_entity( def formatter(value: int) -> int | None: """Return the state of the entity.""" # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯ - if not isinstance(value, numbers.Number) or value == -1: + if not isinstance(value, numbers.Number) or value == -1 or value == 255: return None value = round(value / 2) return value @@ -258,9 +309,10 @@ def extra_state_attributes(self) -> dict[str, Any]: models={"VZM31-SN", "SP 234", "outletv4"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurement(Sensor): +class ElectricalMeasurement(PollableSensor): """Active power measurement.""" + _use_custom_polling: bool = False _attribute_name = "active_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT @@ -306,22 +358,17 @@ def formatter(self, value: int) -> int | float: class PolledElectricalMeasurement(ElectricalMeasurement): """Polled active power measurement.""" - _attr_should_poll = True # BaseZhaEntity defaults to False - - async def async_update(self) -> None: - """Retrieve latest state.""" - if not self.available: - return - await super().async_update() + _use_custom_polling: bool = True @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementApparentPower(ElectricalMeasurement): +class ElectricalMeasurementApparentPower(PolledElectricalMeasurement): """Apparent power measurement.""" _attribute_name = "apparent_power" _unique_id_suffix = "apparent_power" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE _div_mul_prefix = "ac_power" @@ -329,11 +376,12 @@ class ElectricalMeasurementApparentPower(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): +class ElectricalMeasurementRMSCurrent(PolledElectricalMeasurement): """RMS current measurement.""" _attribute_name = "rms_current" _unique_id_suffix = "rms_current" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE _div_mul_prefix = "ac_current" @@ -341,11 +389,12 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): +class ElectricalMeasurementRMSVoltage(PolledElectricalMeasurement): """RMS Voltage measurement.""" _attribute_name = "rms_voltage" _unique_id_suffix = "rms_voltage" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _div_mul_prefix = "ac_voltage" @@ -353,11 +402,12 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementFrequency(ElectricalMeasurement): +class ElectricalMeasurementFrequency(PolledElectricalMeasurement): """Frequency measurement.""" _attribute_name = "ac_frequency" _unique_id_suffix = "ac_frequency" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY _attr_translation_key: str = "ac_frequency" _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ @@ -366,11 +416,12 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementPowerFactor(ElectricalMeasurement): +class ElectricalMeasurementPowerFactor(PolledElectricalMeasurement): """Frequency measurement.""" _attribute_name = "power_factor" _unique_id_suffix = "power_factor" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR _attr_native_unit_of_measurement = PERCENTAGE @@ -440,9 +491,10 @@ def formatter(self, value: int) -> int: stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class SmartEnergyMetering(Sensor): +class SmartEnergyMetering(PollableSensor): """Metering sensor.""" + _use_custom_polling: bool = False _attribute_name = "instantaneous_demand" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT @@ -540,13 +592,7 @@ def formatter(self, value: int) -> int | float: class PolledSmartEnergySummation(SmartEnergySummation): """Polled Smart Energy Metering summation sensor.""" - _attr_should_poll = True # BaseZhaEntity defaults to False - - async def async_update(self) -> None: - """Retrieve latest state.""" - if not self.available: - return - await self._cluster_handler.async_force_update() + _use_custom_polling: bool = True @MULTI_MATCH( @@ -557,6 +603,7 @@ async def async_update(self) -> None: class Tier1SmartEnergySummation(PolledSmartEnergySummation): """Tier 1 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier1_summ_delivered" _unique_id_suffix = "tier1_summation_delivered" _attr_translation_key: str = "tier1_summation_delivered" @@ -570,6 +617,7 @@ class Tier1SmartEnergySummation(PolledSmartEnergySummation): class Tier2SmartEnergySummation(PolledSmartEnergySummation): """Tier 2 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier2_summ_delivered" _unique_id_suffix = "tier2_summation_delivered" _attr_translation_key: str = "tier2_summation_delivered" @@ -583,6 +631,7 @@ class Tier2SmartEnergySummation(PolledSmartEnergySummation): class Tier3SmartEnergySummation(PolledSmartEnergySummation): """Tier 3 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier3_summ_delivered" _unique_id_suffix = "tier3_summation_delivered" _attr_translation_key: str = "tier3_summation_delivered" @@ -596,6 +645,7 @@ class Tier3SmartEnergySummation(PolledSmartEnergySummation): class Tier4SmartEnergySummation(PolledSmartEnergySummation): """Tier 4 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier4_summ_delivered" _unique_id_suffix = "tier4_summation_delivered" _attr_translation_key: str = "tier4_summation_delivered" @@ -609,6 +659,7 @@ class Tier4SmartEnergySummation(PolledSmartEnergySummation): class Tier5SmartEnergySummation(PolledSmartEnergySummation): """Tier 5 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier5_summ_delivered" _unique_id_suffix = "tier5_summation_delivered" _attr_translation_key: str = "tier5_summation_delivered" @@ -622,6 +673,7 @@ class Tier5SmartEnergySummation(PolledSmartEnergySummation): class Tier6SmartEnergySummation(PolledSmartEnergySummation): """Tier 6 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier6_summ_delivered" _unique_id_suffix = "tier6_summation_delivered" _attr_translation_key: str = "tier6_summation_delivered" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 22c2810ad2379c..8909af8a5ba2bb 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -721,6 +721,12 @@ }, "away_preset_temperature": { "name": "Away preset temperature" + }, + "quick_start_time": { + "name": "Quick start time" + }, + "local_temperature_calibration": { + "name": "Local temperature offset" } }, "select": { @@ -766,6 +772,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" }, @@ -774,6 +783,15 @@ }, "preset": { "name": "Preset" + }, + "detection_distance": { + "name": "Detection distance" + }, + "switch_mode": { + "name": "Switch mode" + }, + "decoupled_mode": { + "name": "Decoupled mode" } }, "sensor": { @@ -878,6 +896,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 e49bc44b822a1a..d4e835751f5b2a 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -5,6 +5,7 @@ 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 @@ -107,11 +108,10 @@ async def async_added_to_hass(self) -> None: async def async_update(self) -> None: """Attempt to retrieve on off state from the switch.""" - await super().async_update() - if self._on_off_cluster_handler: - await self._on_off_cluster_handler.get_attribute_value( - "on_off", from_cache=False - ) + self.debug("Polling current state") + await self._on_off_cluster_handler.get_attribute_value( + "on_off", from_cache=False + ) @GROUP_MATCH() @@ -254,16 +254,14 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_update(self) -> None: """Attempt to retrieve the state of the entity.""" - await super().async_update() - self.error("Polling current state") - if self._cluster_handler: - value = await self._cluster_handler.get_attribute_value( - self._attribute_name, from_cache=False - ) - await self._cluster_handler.get_attribute_value( - self._inverter_attribute_name, from_cache=False - ) - self.debug("read value=%s, inverted=%s", value, self.inverted) + self.debug("Polling current state") + value = await self._cluster_handler.get_attribute_value( + self._attribute_name, from_cache=False + ) + await self._cluster_handler.get_attribute_value( + self._inverter_attribute_name, from_cache=False + ) + self.debug("read value=%s, inverted=%s", value, self.inverted) @CONFIG_DIAGNOSTIC_MATCH( @@ -363,6 +361,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 +497,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/__init__.py b/homeassistant/components/zwave_js/__init__.py index d9f85e56c0a060..89da652a22f2b3 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -450,7 +450,10 @@ def async_on_node_removed(self, event: dict) -> None: "remove_entity" ), ) - elif reason == RemoveNodeReason.RESET: + # We don't want to remove the device so we can keep the user customizations + return + + if reason == RemoveNodeReason.RESET: device_name = device.name_by_user or device.name or f"Node {node.node_id}" identifier = get_network_identifier_for_notification( self.hass, self.config_entry, self.driver_events.driver.controller @@ -472,8 +475,8 @@ def async_on_node_removed(self, event: dict) -> None: "Device Was Factory Reset!", f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}", ) - else: - self.remove_device(device) + + self.remove_device(device) @callback def async_on_identify(self, event: dict) -> None: diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index a917aa448893a0..7f4855bfbe567f 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1,10 +1,10 @@ """Websocket API for Z-Wave JS.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import dataclasses from functools import partial, wraps -from typing import Any, Literal, cast +from typing import Any, Concatenate, Literal, ParamSpec, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -85,6 +85,8 @@ get_device_id, ) +_P = ParamSpec("_P") + DATA_UNSUBSCRIBE = "unsubs" # general API constants @@ -264,8 +266,11 @@ def convert_qr_provisioning_information(info: dict) -> QRProvisioningInformation async def _async_get_entry( - hass: HomeAssistant, connection: ActiveConnection, msg: dict, entry_id: str -) -> tuple[ConfigEntry | None, Client | None, Driver | None]: + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry_id: str, +) -> tuple[ConfigEntry, Client, Driver] | tuple[None, None, None]: """Get config entry and client from message data.""" entry = hass.config_entries.async_get_entry(entry_id) if entry is None: @@ -293,19 +298,26 @@ async def _async_get_entry( return entry, client, client.driver -def async_get_entry(orig_func: Callable) -> Callable: +def async_get_entry( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry, Client, Driver], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get entry.""" @wraps(orig_func) async def async_get_entry_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" entry, client, driver = await _async_get_entry( hass, connection, msg, msg[ENTRY_ID] ) - if not entry and not client and not driver: + if not entry or not client or not driver: return await orig_func(hass, connection, msg, entry, client, driver) @@ -328,12 +340,19 @@ async def _async_get_node( return node -def async_get_node(orig_func: Callable) -> Callable: +def async_get_node( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Node], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get node.""" @wraps(orig_func) async def async_get_node_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" node = await _async_get_node(hass, connection, msg, msg[DEVICE_ID]) @@ -344,16 +363,24 @@ async def async_get_node_func( return async_get_node_func -def async_handle_failed_command(orig_func: Callable) -> Callable: +def async_handle_failed_command( + orig_func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate async function to handle FailedCommand and send relevant error.""" @wraps(orig_func) async def async_handle_failed_command_func( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, - *args: Any, - **kwargs: Any, + msg: dict[str, Any], + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle FailedCommand within function and send relevant error.""" try: @@ -393,7 +420,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 +643,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 ef5cdd1b1d22fb..cb460f37000816 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -50,7 +50,7 @@ NOTIFICATION_GAS = "18" -@dataclass +@dataclass(frozen=True) class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription): """Represent a Z-Wave JS binary sensor entity description.""" @@ -58,14 +58,14 @@ class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription): states: tuple[str, ...] | None = None -@dataclass +@dataclass(frozen=True) class PropertyZWaveJSMixin: """Represent the mixin for property sensor descriptions.""" on_states: tuple[str, ...] -@dataclass +@dataclass(frozen=True) class PropertyZWaveJSEntityDescription( BinarySensorEntityDescription, PropertyZWaveJSMixin ): @@ -276,9 +276,7 @@ def async_add_binary_sensor(info: ZwaveDiscoveryInfo) -> None: 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 acc1da4e51a0e5..656620d01ddf1d 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -99,6 +99,7 @@ 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" @@ -118,6 +119,13 @@ # 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/cover.py b/homeassistant/components/zwave_js/cover.py index 364eafd8cafcdd..27919a17614834 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -18,6 +18,7 @@ from zwave_js_server.const.command_class.window_covering import ( NO_POSITION_PROPERTY_KEYS, NO_POSITION_SUFFIX, + WINDOW_COVERING_LEVEL_CHANGE_UP_PROPERTY, SlatStates, ) from zwave_js_server.model.driver import Driver @@ -369,7 +370,7 @@ def __init__( set_values_func( value, stop_value=self.get_zwave_value( - "levelChangeUp", + WINDOW_COVERING_LEVEL_CHANGE_UP_PROPERTY, value_property_key=value.property_key, ), ) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 39d8c0e8855b50..dfe2294e7105a3 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -530,6 +530,68 @@ class ZWaveDiscoverySchema: 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 @@ class ZWaveDiscoverySchema: # 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 d06306497657a3..d4247b65c8b7ee 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -18,7 +18,6 @@ DOMAIN as FAN_DOMAIN, FanEntity, FanEntityFeature, - NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -181,11 +180,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 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/helpers.py b/homeassistant/components/zwave_js/helpers.py index 5d78d3e57e7d21..c8eb02ad6cbdde 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -25,7 +25,6 @@ get_value_id_str, ) -from homeassistant.components.group import expand_entity_ids from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( @@ -39,6 +38,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.group import expand_entity_ids from homeassistant.helpers.typing import ConfigType from .const import ( @@ -456,7 +456,9 @@ def remove_keys_with_empty_values(config: ConfigType) -> ConfigType: return {key: value for key, value in config.items() if value not in ("", None)} -def check_type_schema_map(schema_map: dict[str, vol.Schema]) -> Callable: +def check_type_schema_map( + schema_map: dict[str, vol.Schema], +) -> Callable[[ConfigType], ConfigType]: """Check type specific schema against config.""" def _check_type_schema(config: ConfigType) -> ConfigType: diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index 02c6abbc732a63..14a43bea3af52b 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class ZwaveHumidifierEntityDescriptionRequiredKeys: """A class for humidifier entity description required keys.""" @@ -48,7 +48,7 @@ class ZwaveHumidifierEntityDescriptionRequiredKeys: setpoint_type: HumidityControlSetpointType -@dataclass +@dataclass(frozen=True) class ZwaveHumidifierEntityDescription( HumidifierEntityDescription, ZwaveHumidifierEntityDescriptionRequiredKeys ): diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 5457916a1e1352..59faf7fbbb6602 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -11,10 +11,12 @@ 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.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_LOCKED: True, }, } +UNIT16_SCHEMA = vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) async def async_setup_entry( @@ -92,6 +102,24 @@ def async_add_lock(info: ZwaveDiscoveryInfo) -> None: "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 @@ async def async_set_lock_usercode(self, code_slot: int, usercode: str) -> None: 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 @@ async def async_clear_lock_usercode(self, code_slot: int) -> None: 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/manifest.json b/homeassistant/components/zwave_js/manifest.json index f0c1dcec6b5a96..9a66dae8e9323b 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.53.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.55.2"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 3956004336a03c..e838949d3e172e 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -5,7 +5,8 @@ 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 @@ def async_add_select(info: ZwaveDiscoveryInfo) -> None: 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 @@ async def async_select_option(self, option: str) -> None: 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 @@ def __init__( """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/sensor.py b/homeassistant/components/zwave_js/sensor.py index 8d42bcfb36698f..9e95d430a4cde5 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -111,29 +111,30 @@ tuple[str, str], SensorEntityDescription ] = { (ENTITY_DESC_KEY_BATTERY, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_BATTERY, + key=ENTITY_DESC_KEY_BATTERY, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_CURRENT, UnitOfElectricCurrent.AMPERE): SensorEntityDescription( - ENTITY_DESC_KEY_CURRENT, + key=ENTITY_DESC_KEY_CURRENT, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), (ENTITY_DESC_KEY_VOLTAGE, UnitOfElectricPotential.VOLT): SensorEntityDescription( - ENTITY_DESC_KEY_VOLTAGE, + key=ENTITY_DESC_KEY_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, ), ( ENTITY_DESC_KEY_VOLTAGE, UnitOfElectricPotential.MILLIVOLT, ): SensorEntityDescription( - ENTITY_DESC_KEY_VOLTAGE, + key=ENTITY_DESC_KEY_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, @@ -142,67 +143,67 @@ ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, UnitOfEnergy.KILO_WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + key=ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), (ENTITY_DESC_KEY_POWER, UnitOfPower.WATT): SensorEntityDescription( - ENTITY_DESC_KEY_POWER, + key=ENTITY_DESC_KEY_POWER, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), (ENTITY_DESC_KEY_POWER_FACTOR, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_POWER_FACTOR, + key=ENTITY_DESC_KEY_POWER_FACTOR, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_CO, CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( - ENTITY_DESC_KEY_CO, + key=ENTITY_DESC_KEY_CO, device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), (ENTITY_DESC_KEY_CO2, CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( - ENTITY_DESC_KEY_CO2, + key=ENTITY_DESC_KEY_CO2, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), (ENTITY_DESC_KEY_HUMIDITY, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_HUMIDITY, + key=ENTITY_DESC_KEY_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_ILLUMINANCE, LIGHT_LUX): SensorEntityDescription( - ENTITY_DESC_KEY_ILLUMINANCE, + key=ENTITY_DESC_KEY_ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.KPA): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.KPA, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.PSI): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.PSI, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.INHG): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.INHG, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.MMHG): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.MMHG, @@ -211,7 +212,7 @@ ENTITY_DESC_KEY_SIGNAL_STRENGTH, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ): SensorEntityDescription( - ENTITY_DESC_KEY_SIGNAL_STRENGTH, + key=ENTITY_DESC_KEY_SIGNAL_STRENGTH, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -219,7 +220,7 @@ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ), (ENTITY_DESC_KEY_TEMPERATURE, UnitOfTemperature.CELSIUS): SensorEntityDescription( - ENTITY_DESC_KEY_TEMPERATURE, + key=ENTITY_DESC_KEY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -228,7 +229,7 @@ ENTITY_DESC_KEY_TEMPERATURE, UnitOfTemperature.FAHRENHEIT, ): SensorEntityDescription( - ENTITY_DESC_KEY_TEMPERATURE, + key=ENTITY_DESC_KEY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, @@ -237,7 +238,7 @@ ENTITY_DESC_KEY_TARGET_TEMPERATURE, UnitOfTemperature.CELSIUS, ): SensorEntityDescription( - ENTITY_DESC_KEY_TARGET_TEMPERATURE, + key=ENTITY_DESC_KEY_TARGET_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), @@ -245,7 +246,7 @@ ENTITY_DESC_KEY_TARGET_TEMPERATURE, UnitOfTemperature.FAHRENHEIT, ): SensorEntityDescription( - ENTITY_DESC_KEY_TARGET_TEMPERATURE, + key=ENTITY_DESC_KEY_TARGET_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), @@ -253,13 +254,13 @@ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.SECONDS, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, name="Energy production time", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, ), (ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.HOURS): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, ), @@ -267,7 +268,7 @@ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, UnitOfEnergy.WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, name="Energy production today", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -277,7 +278,7 @@ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, UnitOfEnergy.WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, name="Energy production total", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -287,7 +288,7 @@ ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER, UnitOfPower.WATT, ): SensorEntityDescription( - ENTITY_DESC_KEY_POWER, + key=ENTITY_DESC_KEY_POWER, name="Energy production power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -298,41 +299,41 @@ # These descriptions are without device class. ENTITY_DESCRIPTION_KEY_MAP = { ENTITY_DESC_KEY_CO: SensorEntityDescription( - ENTITY_DESC_KEY_CO, + key=ENTITY_DESC_KEY_CO, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ENERGY_MEASUREMENT: SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + key=ENTITY_DESC_KEY_ENERGY_MEASUREMENT, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_HUMIDITY: SensorEntityDescription( - ENTITY_DESC_KEY_HUMIDITY, + key=ENTITY_DESC_KEY_HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ILLUMINANCE: SensorEntityDescription( - ENTITY_DESC_KEY_ILLUMINANCE, + key=ENTITY_DESC_KEY_ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_POWER_FACTOR: SensorEntityDescription( - ENTITY_DESC_KEY_POWER_FACTOR, + key=ENTITY_DESC_KEY_POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_SIGNAL_STRENGTH: SensorEntityDescription( - ENTITY_DESC_KEY_SIGNAL_STRENGTH, + key=ENTITY_DESC_KEY_SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_MEASUREMENT: SensorEntityDescription( - ENTITY_DESC_KEY_MEASUREMENT, + key=ENTITY_DESC_KEY_MEASUREMENT, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_TOTAL_INCREASING: SensorEntityDescription( - ENTITY_DESC_KEY_TOTAL_INCREASING, + key=ENTITY_DESC_KEY_TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING, ), ENTITY_DESC_KEY_UV_INDEX: SensorEntityDescription( - ENTITY_DESC_KEY_UV_INDEX, + key=ENTITY_DESC_KEY_UV_INDEX, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, ), @@ -342,80 +343,80 @@ # Controller statistics descriptions ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ SensorEntityDescription( - "messagesTX", + key="messagesTX", name="Successful messages (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesRX", + key="messagesRX", name="Successful messages (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesDroppedTX", + key="messagesDroppedTX", name="Messages dropped (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesDroppedRX", + key="messagesDroppedRX", name="Messages dropped (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "NAK", + key="NAK", name="Messages not accepted", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "CAN", name="Collisions", state_class=SensorStateClass.TOTAL + key="CAN", name="Collisions", state_class=SensorStateClass.TOTAL ), SensorEntityDescription( - "timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL + key="timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL ), SensorEntityDescription( - "timeoutResponse", + key="timeoutResponse", name="Timed out responses", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "timeoutCallback", + key="timeoutCallback", name="Timed out callbacks", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "backgroundRSSI.channel0.average", + key="backgroundRSSI.channel0.average", name="Average background RSSI (channel 0)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel0.current", + key="backgroundRSSI.channel0.current", name="Current background RSSI (channel 0)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "backgroundRSSI.channel1.average", + key="backgroundRSSI.channel1.average", name="Average background RSSI (channel 1)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel1.current", + key="backgroundRSSI.channel1.current", name="Current background RSSI (channel 1)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "backgroundRSSI.channel2.average", + key="backgroundRSSI.channel2.average", name="Average background RSSI (channel 2)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel2.current", + key="backgroundRSSI.channel2.current", name="Current background RSSI (channel 2)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -426,39 +427,39 @@ # Node statistics descriptions ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ SensorEntityDescription( - "commandsRX", + key="commandsRX", name="Successful commands (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsTX", + key="commandsTX", name="Successful commands (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsDroppedRX", + key="commandsDroppedRX", name="Commands dropped (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsDroppedTX", + key="commandsDroppedTX", name="Commands dropped (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "timeoutResponse", + key="timeoutResponse", name="Timed out responses", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "rtt", + key="rtt", name="Round Trip Time", native_unit_of_measurement=UnitOfTime.MILLISECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "rssi", + key="rssi", name="RSSI", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -478,7 +479,7 @@ def get_entity_description( ENTITY_DESCRIPTION_KEY_MAP.get( data_description_key, SensorEntityDescription( - "base_sensor", native_unit_of_measurement=data.unit_of_measurement + key="base_sensor", native_unit_of_measurement=data.unit_of_measurement ), ), ) @@ -661,7 +662,7 @@ def native_value(self) -> float: """Return state of the sensor.""" if self.info.primary_value.value is None: return 0 - return round(float(self.info.primary_value.value), 2) + return float(self.info.primary_value.value) class ZWaveMeterSensor(ZWaveNumericSensor): diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 12c1ed242afbfb..e8ef1df4b96a26 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -25,13 +25,13 @@ async_set_config_parameter, ) -from homeassistant.components.group import expand_entity_ids from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.group import expand_entity_ids from . import const from .config_validation import BITMASK_SCHEMA, VALUE_SCHEMA @@ -49,7 +49,7 @@ def parameter_name_does_not_need_bitmask( - val: dict[str, int | str | list[str]] + val: dict[str, int | str | list[str]], ) -> dict[str, int | str | list[str]]: """Validate that if a parameter name is provided, bitmask is not as well.""" if ( diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index cb8e726bf32053..81809e3fbeb745 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: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 71c6b93e2bdc25..19a47450080ffa 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -385,6 +385,44 @@ "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 37cfdc68569128..cf743a3e85a89f 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -344,7 +344,8 @@ async def async_added_to_hass(self) -> None: 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/components/zwave_me/sensor.py b/homeassistant/components/zwave_me/sensor.py index 89048f4fec9d11..f96e2d789ffc52 100644 --- a/homeassistant/components/zwave_me/sensor.py +++ b/homeassistant/components/zwave_me/sensor.py @@ -31,7 +31,7 @@ from .const import DOMAIN, ZWaveMePlatform -@dataclass +@dataclass(frozen=True) class ZWaveMeSensorEntityDescription(SensorEntityDescription): """Class describing ZWaveMeSensor sensor entities.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index 1b7e90996dc533..949774d336128e 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -2,20 +2,25 @@ from __future__ import annotations from collections import OrderedDict -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterable, 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 @@ -43,6 +48,7 @@ CONF_MEDIA_DIRS, CONF_NAME, CONF_PACKAGES, + CONF_PLATFORM, CONF_TEMPERATURE_UNIT, CONF_TIME_ZONE, CONF_TYPE, @@ -51,25 +57,19 @@ __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, - config_validation as cv, - extract_domain_configs, - issue_registry as ir, -) +from .helpers import config_validation as cv, issue_registry as ir from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system -from .util.yaml import SECRET_YAML, Secrets, load_yaml +from .util.yaml import SECRET_YAML, Secrets, YamlTypeError, load_yaml_dict _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 +82,7 @@ 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,8 +114,48 @@ """ +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_path: 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]] + configs: Sequence[dict[str, Any]], ) -> Sequence[dict[str, Any]]: """No duplicate auth provider config allowed in a list. @@ -140,7 +176,7 @@ def _no_duplicate_auth_provider( def _no_duplicate_auth_mfa_module( - configs: Sequence[dict[str, Any]] + configs: Sequence[dict[str, Any]], ) -> Sequence[dict[str, Any]]: """No duplicate auth mfa module item allowed in a list. @@ -236,6 +272,41 @@ def _raise_issue_if_no_country(hass: HomeAssistant, country: str | None) -> None ) +def _raise_issue_if_legacy_templates( + hass: HomeAssistant, legacy_templates: bool | None +) -> None: + # legacy_templates can have the following values: + # - None: Using default value (False) -> Delete repair issues + # - True: Create repair to adopt templates to new syntax + # - False: Create repair to tell user to remove config key + if legacy_templates: + ir.async_create_issue( + hass, + "homeassistant", + "legacy_templates_true", + is_fixable=False, + breaks_in_ha_version="2024.7.0", + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_templates_true", + ) + return + + ir.async_delete_issue(hass, "homeassistant", "legacy_templates_true") + + if legacy_templates is False: + ir.async_create_issue( + hass, + "homeassistant", + "legacy_templates_false", + is_fixable=False, + breaks_in_ha_version="2024.7.0", + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_templates_false", + ) + else: + ir.async_delete_issue(hass, "homeassistant", "legacy_templates_false") + + def _validate_currency(data: Any) -> Any: try: return cv.currency(data) @@ -395,12 +466,37 @@ 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 + + invalid_domains = [] + for key in config: + try: + cv.domain_key(key) + except vol.Invalid as exc: + suffix = "" + if annotation := find_annotation(config, exc.path): + suffix = f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + _LOGGER.error("Invalid domain '%s'%s", key, suffix) + invalid_domains.append(key) + for invalid_domain in invalid_domains: + config.pop(invalid_domain) + core_config = config.get(CONF_CORE, {}) await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) return config @@ -415,15 +511,15 @@ def load_yaml_config_file( This method needs to run in an executor. """ - conf_dict = load_yaml(config_path, secrets) - - if not isinstance(conf_dict, dict): + try: + conf_dict = load_yaml_dict(config_path, secrets) + except YamlTypeError as exc: msg = ( f"The configuration file {os.path.basename(config_path)} " "does not contain a dictionary" ) _LOGGER.error(msg) - raise HomeAssistantError(msg) + raise HomeAssistantError(msg) from exc # Convert values to dictionaries if they are None for key, value in conf_dict.items(): @@ -488,60 +584,236 @@ 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. + """Log a schema validation error.""" + message = format_schema_error(hass, exc, domain, config, link) + _LOGGER.error(message) - This method must be run in the event loop. + +@callback +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 + + 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. """ - 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) + try: + return reduce(operator.getitem, items, data) # type: ignore[arg-type] + except (KeyError, IndexError, TypeError): + return None -@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 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. - This method must be run in the event loop. + 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. """ - 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}." + + 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] + ) ) - else: - message += f"{humanize_error(config, ex)}." - is_friendly = True + ): + 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 + """ + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + message_prefix = f"Invalid config for '{domain}'" + if domain != CONF_CORE and link: + message_suffix = f", please check the docs at {link}" else: - message += str(ex) or repr(ex) + 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}" + ) - try: - domain_config = config.get(domain, config) - except AttributeError: - domain_config = config - message += ( - f" (See {getattr(domain_config, '__config_file__', '?')}, " - f"line {getattr(domain_config, '__line__', '?')}). " +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.""" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + 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}" + message += f", please check the docs at {link}" - return message, is_friendly + 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: @@ -603,6 +875,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non if key in config: setattr(hac, attr, config[key]) + _raise_issue_if_legacy_templates(hass, config.get(CONF_LEGACY_TEMPLATES)) _raise_issue_if_historic_currency(hass, hass.config.currency) _raise_issue_if_no_country(hass, hass.config.country) @@ -663,17 +936,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 +995,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 +1014,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) @@ -758,17 +1031,30 @@ async def merge_packages_config( for comp_name, comp_conf in pack_conf.items(): if comp_name == CONF_CORE: continue - # If component name is given with a trailing description, remove it - # when looking for component - domain = comp_name.partition(" ")[0] + try: + domain = cv.domain_key(comp_name) + except vol.Invalid: + _log_pkg_error( + hass, pack_name, comp_name, config, f"Invalid domain '{comp_name}'" + ) + continue try: integration = await async_get_integration_with_requirements( 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 +1088,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 +1101,257 @@ 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 +@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_path = platform_exception.platform_path + platform_config = platform_exception.config + link = platform_exception.integration_link + + 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_path} 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_path} 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_path, 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_path, platform_config, link + ) + if annotation := find_annotation(platform_config, [platform_path]): + 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: - """Check component configuration and return processed configuration. + """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, + ) + + +def config_per_platform( + config: ConfigType, domain: str +) -> Iterable[tuple[str | None, ConfigType]]: + """Break a component config into different platforms. + + For example, will find 'switch', 'switch 2', 'switch 3', .. etc + Async friendly. + """ + for config_key in extract_domain_configs(config, domain): + if not (platform_config := config[config_key]): + continue + + if not isinstance(platform_config, list): + platform_config = [platform_config] + + item: ConfigType + platform: str | None + for item in platform_config: + try: + platform = item.get(CONF_PLATFORM) + except AttributeError: + platform = None - Returns None on error. + yield platform, item + + +def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: + """Extract keys from config for given domain name. + + Async friendly. + """ + domain_configs = [] + for key in config: + with suppress(vol.Invalid): + if cv.domain_key(key) != domain: + continue + domain_configs.append(key) + return domain_configs + + +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 +1362,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_path = f"{p_name}.{domain}" 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 +1468,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_path, + 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_path, + 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_path, 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 +1524,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 +1549,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 2b8f1ec4065a0a..336261c363219a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -205,6 +205,7 @@ class ConfigEntry: __slots__ = ( "entry_id", "version", + "minor_version", "domain", "title", "data", @@ -233,7 +234,9 @@ class ConfigEntry: def __init__( self, + *, version: int, + minor_version: int, domain: str, title: str, data: Mapping[str, Any], @@ -252,6 +255,7 @@ def __init__( # Version of the configuration. self.version = version + self.minor_version = minor_version # Domain the configuration belongs to self.domain = domain @@ -406,8 +410,8 @@ async def async_setup( "%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 +420,8 @@ async def async_setup( ) 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 +436,13 @@ async def async_setup( 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 +569,13 @@ async def async_unload( 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 @@ -631,7 +635,8 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: while isinstance(handler, functools.partial): handler = handler.func # type: ignore[unreachable] - if self.version == handler.VERSION: + same_major_version = self.version == handler.VERSION + if same_major_version and self.minor_version == handler.MINOR_VERSION: return True if not (integration := self._integration_for_domain): @@ -639,6 +644,8 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: component = integration.get_component() supports_migrate = hasattr(component, "async_migrate_entry") if not supports_migrate: + if same_major_version: + return True _LOGGER.error( "Migration handler not found for entry %s for %s", self.title, @@ -676,6 +683,7 @@ def as_dict(self) -> dict[str, Any]: return { "entry_id": self.entry_id, "version": self.version, + "minor_version": self.minor_version, "domain": self.domain, "title": self.title, "data": dict(self.data), @@ -974,6 +982,7 @@ async def async_finish_flow( entry = ConfigEntry( version=result["version"], + minor_version=result["minor_version"], domain=result["handler"], title=result["title"], data=result["data"], @@ -1196,6 +1205,7 @@ async def async_initialize(self) -> None: config_entry = ConfigEntry( version=entry["version"], + minor_version=entry.get("minor_version", 1), domain=domain, entry_id=entry_id, data=entry["data"], diff --git a/homeassistant/const.py b/homeassistant/const.py index c6655ba3900762..3aa0a75729eca3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,11 +2,19 @@ from __future__ import annotations from enum import StrEnum +from functools import partial from typing import Final +from .helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + APPLICATION_NAME: Final = "HomeAssistant" -MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 12 +MAJOR_VERSION: Final = 2024 +MINOR_VERSION: Final = 2 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" @@ -58,6 +66,7 @@ class Platform(StrEnum): TODO = "todo" TTS = "tts" VACUUM = "vacuum" + VALVE = "valve" UPDATE = "update" WAKE_WORD = "wake_word" WATER_HEATER = "water_heater" @@ -129,6 +138,7 @@ class Platform(StrEnum): CONF_CONTINUE_ON_TIMEOUT: Final = "continue_on_timeout" CONF_COUNT: Final = "count" CONF_COUNTRY: Final = "country" +CONF_COUNTRY_CODE: Final = "country_code" CONF_COVERS: Final = "covers" CONF_CURRENCY: Final = "currency" CONF_CUSTOMIZE: Final = "customize" @@ -305,34 +315,136 @@ class Platform(StrEnum): # #### DEVICE CLASSES #### # DEVICE_CLASS_* below are deprecated as of 2021.12 # use the SensorDeviceClass enum instead. -DEVICE_CLASS_AQI: Final = "aqi" -DEVICE_CLASS_BATTERY: Final = "battery" -DEVICE_CLASS_CO: Final = "carbon_monoxide" -DEVICE_CLASS_CO2: Final = "carbon_dioxide" -DEVICE_CLASS_CURRENT: Final = "current" -DEVICE_CLASS_DATE: Final = "date" -DEVICE_CLASS_ENERGY: Final = "energy" -DEVICE_CLASS_FREQUENCY: Final = "frequency" -DEVICE_CLASS_GAS: Final = "gas" -DEVICE_CLASS_HUMIDITY: Final = "humidity" -DEVICE_CLASS_ILLUMINANCE: Final = "illuminance" -DEVICE_CLASS_MONETARY: Final = "monetary" -DEVICE_CLASS_NITROGEN_DIOXIDE = "nitrogen_dioxide" -DEVICE_CLASS_NITROGEN_MONOXIDE = "nitrogen_monoxide" -DEVICE_CLASS_NITROUS_OXIDE = "nitrous_oxide" -DEVICE_CLASS_OZONE: Final = "ozone" -DEVICE_CLASS_PM1: Final = "pm1" -DEVICE_CLASS_PM10: Final = "pm10" -DEVICE_CLASS_PM25: Final = "pm25" -DEVICE_CLASS_POWER_FACTOR: Final = "power_factor" -DEVICE_CLASS_POWER: Final = "power" -DEVICE_CLASS_PRESSURE: Final = "pressure" -DEVICE_CLASS_SIGNAL_STRENGTH: Final = "signal_strength" -DEVICE_CLASS_SULPHUR_DIOXIDE = "sulphur_dioxide" -DEVICE_CLASS_TEMPERATURE: Final = "temperature" -DEVICE_CLASS_TIMESTAMP: Final = "timestamp" -DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" -DEVICE_CLASS_VOLTAGE: Final = "voltage" +_DEPRECATED_DEVICE_CLASS_AQI: Final = DeprecatedConstant( + "aqi", "SensorDeviceClass.AQI", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_BATTERY: Final = DeprecatedConstant( + "battery", + "SensorDeviceClass.BATTERY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_CO: Final = DeprecatedConstant( + "carbon_monoxide", + "SensorDeviceClass.CO", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_CO2: Final = DeprecatedConstant( + "carbon_dioxide", + "SensorDeviceClass.CO2", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_CURRENT: Final = DeprecatedConstant( + "current", + "SensorDeviceClass.CURRENT", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_DATE: Final = DeprecatedConstant( + "date", "SensorDeviceClass.DATE", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_ENERGY: Final = DeprecatedConstant( + "energy", + "SensorDeviceClass.ENERGY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_FREQUENCY: Final = DeprecatedConstant( + "frequency", + "SensorDeviceClass.FREQUENCY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_GAS: Final = DeprecatedConstant( + "gas", "SensorDeviceClass.GAS", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_HUMIDITY: Final = DeprecatedConstant( + "humidity", + "SensorDeviceClass.HUMIDITY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_ILLUMINANCE: Final = DeprecatedConstant( + "illuminance", + "SensorDeviceClass.ILLUMINANCE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_MONETARY: Final = DeprecatedConstant( + "monetary", + "SensorDeviceClass.MONETARY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE: Final = DeprecatedConstant( + "nitrogen_dioxide", + "SensorDeviceClass.NITROGEN_DIOXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE: Final = DeprecatedConstant( + "nitrogen_monoxide", + "SensorDeviceClass.NITROGEN_MONOXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE: Final = DeprecatedConstant( + "nitrous_oxide", + "SensorDeviceClass.NITROUS_OXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_OZONE: Final = DeprecatedConstant( + "ozone", "SensorDeviceClass.OZONE", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PM1: Final = DeprecatedConstant( + "pm1", "SensorDeviceClass.PM1", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PM10: Final = DeprecatedConstant( + "pm10", "SensorDeviceClass.PM10", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PM25: Final = DeprecatedConstant( + "pm25", "SensorDeviceClass.PM25", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_POWER_FACTOR: Final = DeprecatedConstant( + "power_factor", + "SensorDeviceClass.POWER_FACTOR", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_POWER: Final = DeprecatedConstant( + "power", "SensorDeviceClass.POWER", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PRESSURE: Final = DeprecatedConstant( + "pressure", + "SensorDeviceClass.PRESSURE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH: Final = DeprecatedConstant( + "signal_strength", + "SensorDeviceClass.SIGNAL_STRENGTH", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE: Final = DeprecatedConstant( + "sulphur_dioxide", + "SensorDeviceClass.SULPHUR_DIOXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_TEMPERATURE: Final = DeprecatedConstant( + "temperature", + "SensorDeviceClass.TEMPERATURE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_TIMESTAMP: Final = DeprecatedConstant( + "timestamp", + "SensorDeviceClass.TIMESTAMP", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: Final = DeprecatedConstant( + "volatile_organic_compounds", + "SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_VOLTAGE: Final = DeprecatedConstant( + "voltage", + "SensorDeviceClass.VOLTAGE", + "2025.1", +) + + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + # #### STATES #### STATE_ON: Final = "on" @@ -506,7 +618,10 @@ class UnitOfApparentPower(StrEnum): VOLT_AMPERE = "VA" -POWER_VOLT_AMPERE: Final = "VA" +_DEPRECATED_POWER_VOLT_AMPERE: Final = DeprecatedConstantEnum( + UnitOfApparentPower.VOLT_AMPERE, + "2025.1", +) """Deprecated: please use UnitOfApparentPower.VOLT_AMPERE.""" @@ -519,11 +634,20 @@ class UnitOfPower(StrEnum): BTU_PER_HOUR = "BTU/h" -POWER_WATT: Final = "W" +_DEPRECATED_POWER_WATT: Final = DeprecatedConstantEnum( + UnitOfPower.WATT, + "2025.1", +) """Deprecated: please use UnitOfPower.WATT.""" -POWER_KILO_WATT: Final = "kW" +_DEPRECATED_POWER_KILO_WATT: Final = DeprecatedConstantEnum( + UnitOfPower.KILO_WATT, + "2025.1", +) """Deprecated: please use UnitOfPower.KILO_WATT.""" -POWER_BTU_PER_HOUR: Final = "BTU/h" +_DEPRECATED_POWER_BTU_PER_HOUR: Final = DeprecatedConstantEnum( + UnitOfPower.BTU_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfPower.BTU_PER_HOUR.""" # Reactive power units @@ -541,11 +665,20 @@ class UnitOfEnergy(StrEnum): WATT_HOUR = "Wh" -ENERGY_KILO_WATT_HOUR: Final = "kWh" +_DEPRECATED_ENERGY_KILO_WATT_HOUR: Final = DeprecatedConstantEnum( + UnitOfEnergy.KILO_WATT_HOUR, + "2025.1", +) """Deprecated: please use UnitOfEnergy.KILO_WATT_HOUR.""" -ENERGY_MEGA_WATT_HOUR: Final = "MWh" +_DEPRECATED_ENERGY_MEGA_WATT_HOUR: Final = DeprecatedConstantEnum( + UnitOfEnergy.MEGA_WATT_HOUR, + "2025.1", +) """Deprecated: please use UnitOfEnergy.MEGA_WATT_HOUR.""" -ENERGY_WATT_HOUR: Final = "Wh" +_DEPRECATED_ENERGY_WATT_HOUR: Final = DeprecatedConstantEnum( + UnitOfEnergy.WATT_HOUR, + "2025.1", +) """Deprecated: please use UnitOfEnergy.WATT_HOUR.""" @@ -557,9 +690,15 @@ class UnitOfElectricCurrent(StrEnum): AMPERE = "A" -ELECTRIC_CURRENT_MILLIAMPERE: Final = "mA" +_DEPRECATED_ELECTRIC_CURRENT_MILLIAMPERE: Final = DeprecatedConstantEnum( + UnitOfElectricCurrent.MILLIAMPERE, + "2025.1", +) """Deprecated: please use UnitOfElectricCurrent.MILLIAMPERE.""" -ELECTRIC_CURRENT_AMPERE: Final = "A" +_DEPRECATED_ELECTRIC_CURRENT_AMPERE: Final = DeprecatedConstantEnum( + UnitOfElectricCurrent.AMPERE, + "2025.1", +) """Deprecated: please use UnitOfElectricCurrent.AMPERE.""" @@ -571,9 +710,15 @@ class UnitOfElectricPotential(StrEnum): VOLT = "V" -ELECTRIC_POTENTIAL_MILLIVOLT: Final = "mV" +_DEPRECATED_ELECTRIC_POTENTIAL_MILLIVOLT: Final = DeprecatedConstantEnum( + UnitOfElectricPotential.MILLIVOLT, + "2025.1", +) """Deprecated: please use UnitOfElectricPotential.MILLIVOLT.""" -ELECTRIC_POTENTIAL_VOLT: Final = "V" +_DEPRECATED_ELECTRIC_POTENTIAL_VOLT: Final = DeprecatedConstantEnum( + UnitOfElectricPotential.VOLT, + "2025.1", +) """Deprecated: please use UnitOfElectricPotential.VOLT.""" # Degree units @@ -594,11 +739,20 @@ class UnitOfTemperature(StrEnum): KELVIN = "K" -TEMP_CELSIUS: Final = "°C" +_DEPRECATED_TEMP_CELSIUS: Final = DeprecatedConstantEnum( + UnitOfTemperature.CELSIUS, + "2025.1", +) """Deprecated: please use UnitOfTemperature.CELSIUS""" -TEMP_FAHRENHEIT: Final = "°F" +_DEPRECATED_TEMP_FAHRENHEIT: Final = DeprecatedConstantEnum( + UnitOfTemperature.FAHRENHEIT, + "2025.1", +) """Deprecated: please use UnitOfTemperature.FAHRENHEIT""" -TEMP_KELVIN: Final = "K" +_DEPRECATED_TEMP_KELVIN: Final = DeprecatedConstantEnum( + UnitOfTemperature.KELVIN, + "2025.1", +) """Deprecated: please use UnitOfTemperature.KELVIN""" @@ -617,23 +771,50 @@ class UnitOfTime(StrEnum): YEARS = "y" -TIME_MICROSECONDS: Final = "μs" +_DEPRECATED_TIME_MICROSECONDS: Final = DeprecatedConstantEnum( + UnitOfTime.MICROSECONDS, + "2025.1", +) """Deprecated: please use UnitOfTime.MICROSECONDS.""" -TIME_MILLISECONDS: Final = "ms" +_DEPRECATED_TIME_MILLISECONDS: Final = DeprecatedConstantEnum( + UnitOfTime.MILLISECONDS, + "2025.1", +) """Deprecated: please use UnitOfTime.MILLISECONDS.""" -TIME_SECONDS: Final = "s" +_DEPRECATED_TIME_SECONDS: Final = DeprecatedConstantEnum( + UnitOfTime.SECONDS, + "2025.1", +) """Deprecated: please use UnitOfTime.SECONDS.""" -TIME_MINUTES: Final = "min" +_DEPRECATED_TIME_MINUTES: Final = DeprecatedConstantEnum( + UnitOfTime.MINUTES, + "2025.1", +) """Deprecated: please use UnitOfTime.MINUTES.""" -TIME_HOURS: Final = "h" +_DEPRECATED_TIME_HOURS: Final = DeprecatedConstantEnum( + UnitOfTime.HOURS, + "2025.1", +) """Deprecated: please use UnitOfTime.HOURS.""" -TIME_DAYS: Final = "d" +_DEPRECATED_TIME_DAYS: Final = DeprecatedConstantEnum( + UnitOfTime.DAYS, + "2025.1", +) """Deprecated: please use UnitOfTime.DAYS.""" -TIME_WEEKS: Final = "w" +_DEPRECATED_TIME_WEEKS: Final = DeprecatedConstantEnum( + UnitOfTime.WEEKS, + "2025.1", +) """Deprecated: please use UnitOfTime.WEEKS.""" -TIME_MONTHS: Final = "m" +_DEPRECATED_TIME_MONTHS: Final = DeprecatedConstantEnum( + UnitOfTime.MONTHS, + "2025.1", +) """Deprecated: please use UnitOfTime.MONTHS.""" -TIME_YEARS: Final = "y" +_DEPRECATED_TIME_YEARS: Final = DeprecatedConstantEnum( + UnitOfTime.YEARS, + "2025.1", +) """Deprecated: please use UnitOfTime.YEARS.""" @@ -651,21 +832,45 @@ class UnitOfLength(StrEnum): MILES = "mi" -LENGTH_MILLIMETERS: Final = "mm" +_DEPRECATED_LENGTH_MILLIMETERS: Final = DeprecatedConstantEnum( + UnitOfLength.MILLIMETERS, + "2025.1", +) """Deprecated: please use UnitOfLength.MILLIMETERS.""" -LENGTH_CENTIMETERS: Final = "cm" +_DEPRECATED_LENGTH_CENTIMETERS: Final = DeprecatedConstantEnum( + UnitOfLength.CENTIMETERS, + "2025.1", +) """Deprecated: please use UnitOfLength.CENTIMETERS.""" -LENGTH_METERS: Final = "m" +_DEPRECATED_LENGTH_METERS: Final = DeprecatedConstantEnum( + UnitOfLength.METERS, + "2025.1", +) """Deprecated: please use UnitOfLength.METERS.""" -LENGTH_KILOMETERS: Final = "km" +_DEPRECATED_LENGTH_KILOMETERS: Final = DeprecatedConstantEnum( + UnitOfLength.KILOMETERS, + "2025.1", +) """Deprecated: please use UnitOfLength.KILOMETERS.""" -LENGTH_INCHES: Final = "in" +_DEPRECATED_LENGTH_INCHES: Final = DeprecatedConstantEnum( + UnitOfLength.INCHES, + "2025.1", +) """Deprecated: please use UnitOfLength.INCHES.""" -LENGTH_FEET: Final = "ft" +_DEPRECATED_LENGTH_FEET: Final = DeprecatedConstantEnum( + UnitOfLength.FEET, + "2025.1", +) """Deprecated: please use UnitOfLength.FEET.""" -LENGTH_YARD: Final = "yd" +_DEPRECATED_LENGTH_YARD: Final = DeprecatedConstantEnum( + UnitOfLength.YARDS, + "2025.1", +) """Deprecated: please use UnitOfLength.YARDS.""" -LENGTH_MILES: Final = "mi" +_DEPRECATED_LENGTH_MILES: Final = DeprecatedConstantEnum( + UnitOfLength.MILES, + "2025.1", +) """Deprecated: please use UnitOfLength.MILES.""" @@ -679,13 +884,25 @@ class UnitOfFrequency(StrEnum): GIGAHERTZ = "GHz" -FREQUENCY_HERTZ: Final = "Hz" +_DEPRECATED_FREQUENCY_HERTZ: Final = DeprecatedConstantEnum( + UnitOfFrequency.HERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.HERTZ""" -FREQUENCY_KILOHERTZ: Final = "kHz" +_DEPRECATED_FREQUENCY_KILOHERTZ: Final = DeprecatedConstantEnum( + UnitOfFrequency.KILOHERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.KILOHERTZ""" -FREQUENCY_MEGAHERTZ: Final = "MHz" +_DEPRECATED_FREQUENCY_MEGAHERTZ: Final = DeprecatedConstantEnum( + UnitOfFrequency.MEGAHERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.MEGAHERTZ""" -FREQUENCY_GIGAHERTZ: Final = "GHz" +_DEPRECATED_FREQUENCY_GIGAHERTZ: Final = DeprecatedConstantEnum( + UnitOfFrequency.GIGAHERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.GIGAHERTZ""" @@ -704,23 +921,50 @@ class UnitOfPressure(StrEnum): PSI = "psi" -PRESSURE_PA: Final = "Pa" +_DEPRECATED_PRESSURE_PA: Final = DeprecatedConstantEnum( + UnitOfPressure.PA, + "2025.1", +) """Deprecated: please use UnitOfPressure.PA""" -PRESSURE_HPA: Final = "hPa" +_DEPRECATED_PRESSURE_HPA: Final = DeprecatedConstantEnum( + UnitOfPressure.HPA, + "2025.1", +) """Deprecated: please use UnitOfPressure.HPA""" -PRESSURE_KPA: Final = "kPa" +_DEPRECATED_PRESSURE_KPA: Final = DeprecatedConstantEnum( + UnitOfPressure.KPA, + "2025.1", +) """Deprecated: please use UnitOfPressure.KPA""" -PRESSURE_BAR: Final = "bar" +_DEPRECATED_PRESSURE_BAR: Final = DeprecatedConstantEnum( + UnitOfPressure.BAR, + "2025.1", +) """Deprecated: please use UnitOfPressure.BAR""" -PRESSURE_CBAR: Final = "cbar" +_DEPRECATED_PRESSURE_CBAR: Final = DeprecatedConstantEnum( + UnitOfPressure.CBAR, + "2025.1", +) """Deprecated: please use UnitOfPressure.CBAR""" -PRESSURE_MBAR: Final = "mbar" +_DEPRECATED_PRESSURE_MBAR: Final = DeprecatedConstantEnum( + UnitOfPressure.MBAR, + "2025.1", +) """Deprecated: please use UnitOfPressure.MBAR""" -PRESSURE_MMHG: Final = "mmHg" +_DEPRECATED_PRESSURE_MMHG: Final = DeprecatedConstantEnum( + UnitOfPressure.MMHG, + "2025.1", +) """Deprecated: please use UnitOfPressure.MMHG""" -PRESSURE_INHG: Final = "inHg" +_DEPRECATED_PRESSURE_INHG: Final = DeprecatedConstantEnum( + UnitOfPressure.INHG, + "2025.1", +) """Deprecated: please use UnitOfPressure.INHG""" -PRESSURE_PSI: Final = "psi" +_DEPRECATED_PRESSURE_PSI: Final = DeprecatedConstantEnum( + UnitOfPressure.PSI, + "2025.1", +) """Deprecated: please use UnitOfPressure.PSI""" @@ -732,9 +976,15 @@ class UnitOfSoundPressure(StrEnum): WEIGHTED_DECIBEL_A = "dBA" -SOUND_PRESSURE_DB: Final = "dB" +_DEPRECATED_SOUND_PRESSURE_DB: Final = DeprecatedConstantEnum( + UnitOfSoundPressure.DECIBEL, + "2025.1", +) """Deprecated: please use UnitOfSoundPressure.DECIBEL""" -SOUND_PRESSURE_WEIGHTED_DBA: Final = "dBa" +_DEPRECATED_SOUND_PRESSURE_WEIGHTED_DBA: Final = DeprecatedConstantEnum( + UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + "2025.1", +) """Deprecated: please use UnitOfSoundPressure.WEIGHTED_DECIBEL_A""" @@ -757,18 +1007,36 @@ class UnitOfVolume(StrEnum): British/Imperial fluid ounces are not yet supported""" -VOLUME_LITERS: Final = "L" +_DEPRECATED_VOLUME_LITERS: Final = DeprecatedConstantEnum( + UnitOfVolume.LITERS, + "2025.1", +) """Deprecated: please use UnitOfVolume.LITERS""" -VOLUME_MILLILITERS: Final = "mL" +_DEPRECATED_VOLUME_MILLILITERS: Final = DeprecatedConstantEnum( + UnitOfVolume.MILLILITERS, + "2025.1", +) """Deprecated: please use UnitOfVolume.MILLILITERS""" -VOLUME_CUBIC_METERS: Final = "m³" +_DEPRECATED_VOLUME_CUBIC_METERS: Final = DeprecatedConstantEnum( + UnitOfVolume.CUBIC_METERS, + "2025.1", +) """Deprecated: please use UnitOfVolume.CUBIC_METERS""" -VOLUME_CUBIC_FEET: Final = "ft³" +_DEPRECATED_VOLUME_CUBIC_FEET: Final = DeprecatedConstantEnum( + UnitOfVolume.CUBIC_FEET, + "2025.1", +) """Deprecated: please use UnitOfVolume.CUBIC_FEET""" -VOLUME_GALLONS: Final = "gal" +_DEPRECATED_VOLUME_GALLONS: Final = DeprecatedConstantEnum( + UnitOfVolume.GALLONS, + "2025.1", +) """Deprecated: please use UnitOfVolume.GALLONS""" -VOLUME_FLUID_OUNCE: Final = "fl. oz." +_DEPRECATED_VOLUME_FLUID_OUNCE: Final = DeprecatedConstantEnum( + UnitOfVolume.FLUID_OUNCES, + "2025.1", +) """Deprecated: please use UnitOfVolume.FLUID_OUNCES""" @@ -780,9 +1048,15 @@ class UnitOfVolumeFlowRate(StrEnum): CUBIC_FEET_PER_MINUTE = "ft³/m" -VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = "m³/h" +_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = DeprecatedConstantEnum( + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR""" -VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = "ft³/m" +_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = DeprecatedConstantEnum( + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + "2025.1", +) """Deprecated: please use UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE""" # Area units @@ -802,17 +1076,35 @@ class UnitOfMass(StrEnum): STONES = "st" -MASS_GRAMS: Final = "g" +_DEPRECATED_MASS_GRAMS: Final = DeprecatedConstantEnum( + UnitOfMass.GRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.GRAMS""" -MASS_KILOGRAMS: Final = "kg" +_DEPRECATED_MASS_KILOGRAMS: Final = DeprecatedConstantEnum( + UnitOfMass.KILOGRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.KILOGRAMS""" -MASS_MILLIGRAMS: Final = "mg" +_DEPRECATED_MASS_MILLIGRAMS: Final = DeprecatedConstantEnum( + UnitOfMass.MILLIGRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.MILLIGRAMS""" -MASS_MICROGRAMS: Final = "µg" +_DEPRECATED_MASS_MICROGRAMS: Final = DeprecatedConstantEnum( + UnitOfMass.MICROGRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.MICROGRAMS""" -MASS_OUNCES: Final = "oz" +_DEPRECATED_MASS_OUNCES: Final = DeprecatedConstantEnum( + UnitOfMass.OUNCES, + "2025.1", +) """Deprecated: please use UnitOfMass.OUNCES""" -MASS_POUNDS: Final = "lb" +_DEPRECATED_MASS_POUNDS: Final = DeprecatedConstantEnum( + UnitOfMass.POUNDS, + "2025.1", +) """Deprecated: please use UnitOfMass.POUNDS""" # Conductivity units @@ -840,9 +1132,15 @@ class UnitOfIrradiance(StrEnum): # Irradiation units -IRRADIATION_WATTS_PER_SQUARE_METER: Final = "W/m²" +_DEPRECATED_IRRADIATION_WATTS_PER_SQUARE_METER: Final = DeprecatedConstantEnum( + UnitOfIrradiance.WATTS_PER_SQUARE_METER, + "2025.1", +) """Deprecated: please use UnitOfIrradiance.WATTS_PER_SQUARE_METER""" -IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = "BTU/(h×ft²)" +_DEPRECATED_IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = DeprecatedConstantEnum( + UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT, + "2025.1", +) """Deprecated: please use UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT""" @@ -884,13 +1182,24 @@ class UnitOfPrecipitationDepth(StrEnum): # Precipitation units -PRECIPITATION_INCHES: Final = "in" +_DEPRECATED_PRECIPITATION_INCHES: Final = DeprecatedConstantEnum( + UnitOfPrecipitationDepth.INCHES, "2025.1" +) """Deprecated: please use UnitOfPrecipitationDepth.INCHES""" -PRECIPITATION_MILLIMETERS: Final = "mm" +_DEPRECATED_PRECIPITATION_MILLIMETERS: Final = DeprecatedConstantEnum( + UnitOfPrecipitationDepth.MILLIMETERS, + "2025.1", +) """Deprecated: please use UnitOfPrecipitationDepth.MILLIMETERS""" -PRECIPITATION_MILLIMETERS_PER_HOUR: Final = "mm/h" +_DEPRECATED_PRECIPITATION_MILLIMETERS_PER_HOUR: Final = DeprecatedConstantEnum( + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR""" -PRECIPITATION_INCHES_PER_HOUR: Final = "in/h" +_DEPRECATED_PRECIPITATION_INCHES_PER_HOUR: Final = DeprecatedConstantEnum( + UnitOfVolumetricFlux.INCHES_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" # Concentration units @@ -913,24 +1222,42 @@ class UnitOfSpeed(StrEnum): MILES_PER_HOUR = "mph" -SPEED_FEET_PER_SECOND: Final = "ft/s" +_DEPRECATED_SPEED_FEET_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfSpeed.FEET_PER_SECOND, "2025.1" +) """Deprecated: please use UnitOfSpeed.FEET_PER_SECOND""" -SPEED_METERS_PER_SECOND: Final = "m/s" +_DEPRECATED_SPEED_METERS_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfSpeed.METERS_PER_SECOND, "2025.1" +) """Deprecated: please use UnitOfSpeed.METERS_PER_SECOND""" -SPEED_KILOMETERS_PER_HOUR: Final = "km/h" +_DEPRECATED_SPEED_KILOMETERS_PER_HOUR: Final = DeprecatedConstantEnum( + UnitOfSpeed.KILOMETERS_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfSpeed.KILOMETERS_PER_HOUR""" -SPEED_KNOTS: Final = "kn" +_DEPRECATED_SPEED_KNOTS: Final = DeprecatedConstantEnum(UnitOfSpeed.KNOTS, "2025.1") """Deprecated: please use UnitOfSpeed.KNOTS""" -SPEED_MILES_PER_HOUR: Final = "mph" +_DEPRECATED_SPEED_MILES_PER_HOUR: Final = DeprecatedConstantEnum( + UnitOfSpeed.MILES_PER_HOUR, "2025.1" +) """Deprecated: please use UnitOfSpeed.MILES_PER_HOUR""" -SPEED_MILLIMETERS_PER_DAY: Final = "mm/d" +_DEPRECATED_SPEED_MILLIMETERS_PER_DAY: Final = DeprecatedConstantEnum( + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_DAY""" -SPEED_INCHES_PER_DAY: Final = "in/d" +_DEPRECATED_SPEED_INCHES_PER_DAY: Final = DeprecatedConstantEnum( + UnitOfVolumetricFlux.INCHES_PER_DAY, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_DAY""" -SPEED_INCHES_PER_HOUR: Final = "in/h" +_DEPRECATED_SPEED_INCHES_PER_HOUR: Final = DeprecatedConstantEnum( + UnitOfVolumetricFlux.INCHES_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" @@ -966,47 +1293,87 @@ class UnitOfInformation(StrEnum): YOBIBYTES = "YiB" -DATA_BITS: Final = "bit" +_DEPRECATED_DATA_BITS: Final = DeprecatedConstantEnum(UnitOfInformation.BITS, "2025.1") """Deprecated: please use UnitOfInformation.BITS""" -DATA_KILOBITS: Final = "kbit" +_DEPRECATED_DATA_KILOBITS: Final = DeprecatedConstantEnum( + UnitOfInformation.KILOBITS, "2025.1" +) """Deprecated: please use UnitOfInformation.KILOBITS""" -DATA_MEGABITS: Final = "Mbit" +_DEPRECATED_DATA_MEGABITS: Final = DeprecatedConstantEnum( + UnitOfInformation.MEGABITS, "2025.1" +) """Deprecated: please use UnitOfInformation.MEGABITS""" -DATA_GIGABITS: Final = "Gbit" +_DEPRECATED_DATA_GIGABITS: Final = DeprecatedConstantEnum( + UnitOfInformation.GIGABITS, "2025.1" +) """Deprecated: please use UnitOfInformation.GIGABITS""" -DATA_BYTES: Final = "B" +_DEPRECATED_DATA_BYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.BYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.BYTES""" -DATA_KILOBYTES: Final = "kB" +_DEPRECATED_DATA_KILOBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.KILOBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.KILOBYTES""" -DATA_MEGABYTES: Final = "MB" +_DEPRECATED_DATA_MEGABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.MEGABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.MEGABYTES""" -DATA_GIGABYTES: Final = "GB" +_DEPRECATED_DATA_GIGABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.GIGABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.GIGABYTES""" -DATA_TERABYTES: Final = "TB" +_DEPRECATED_DATA_TERABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.TERABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.TERABYTES""" -DATA_PETABYTES: Final = "PB" +_DEPRECATED_DATA_PETABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.PETABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.PETABYTES""" -DATA_EXABYTES: Final = "EB" +_DEPRECATED_DATA_EXABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.EXABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.EXABYTES""" -DATA_ZETTABYTES: Final = "ZB" +_DEPRECATED_DATA_ZETTABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.ZETTABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.ZETTABYTES""" -DATA_YOTTABYTES: Final = "YB" +_DEPRECATED_DATA_YOTTABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.YOTTABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.YOTTABYTES""" -DATA_KIBIBYTES: Final = "KiB" +_DEPRECATED_DATA_KIBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.KIBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.KIBIBYTES""" -DATA_MEBIBYTES: Final = "MiB" +_DEPRECATED_DATA_MEBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.MEBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.MEBIBYTES""" -DATA_GIBIBYTES: Final = "GiB" +_DEPRECATED_DATA_GIBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.GIBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.GIBIBYTES""" -DATA_TEBIBYTES: Final = "TiB" +_DEPRECATED_DATA_TEBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.TEBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.TEBIBYTES""" -DATA_PEBIBYTES: Final = "PiB" +_DEPRECATED_DATA_PEBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.PEBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.PEBIBYTES""" -DATA_EXBIBYTES: Final = "EiB" +_DEPRECATED_DATA_EXBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.EXBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.EXBIBYTES""" -DATA_ZEBIBYTES: Final = "ZiB" +_DEPRECATED_DATA_ZEBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.ZEBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.ZEBIBYTES""" -DATA_YOBIBYTES: Final = "YiB" +_DEPRECATED_DATA_YOBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.YOBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.YOBIBYTES""" @@ -1027,27 +1394,60 @@ class UnitOfDataRate(StrEnum): GIBIBYTES_PER_SECOND = "GiB/s" -DATA_RATE_BITS_PER_SECOND: Final = "bit/s" +_DEPRECATED_DATA_RATE_BITS_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.BITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.BITS_PER_SECOND""" -DATA_RATE_KILOBITS_PER_SECOND: Final = "kbit/s" +_DEPRECATED_DATA_RATE_KILOBITS_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.KILOBITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.KILOBITS_PER_SECOND""" -DATA_RATE_MEGABITS_PER_SECOND: Final = "Mbit/s" +_DEPRECATED_DATA_RATE_MEGABITS_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.MEGABITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.MEGABITS_PER_SECOND""" -DATA_RATE_GIGABITS_PER_SECOND: Final = "Gbit/s" +_DEPRECATED_DATA_RATE_GIGABITS_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.GIGABITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.GIGABITS_PER_SECOND""" -DATA_RATE_BYTES_PER_SECOND: Final = "B/s" +_DEPRECATED_DATA_RATE_BYTES_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.BYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.BYTES_PER_SECOND""" -DATA_RATE_KILOBYTES_PER_SECOND: Final = "kB/s" +_DEPRECATED_DATA_RATE_KILOBYTES_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.KILOBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.KILOBYTES_PER_SECOND""" -DATA_RATE_MEGABYTES_PER_SECOND: Final = "MB/s" +_DEPRECATED_DATA_RATE_MEGABYTES_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.MEGABYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.MEGABYTES_PER_SECOND""" -DATA_RATE_GIGABYTES_PER_SECOND: Final = "GB/s" +_DEPRECATED_DATA_RATE_GIGABYTES_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.GIGABYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.GIGABYTES_PER_SECOND""" -DATA_RATE_KIBIBYTES_PER_SECOND: Final = "KiB/s" +_DEPRECATED_DATA_RATE_KIBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.KIBIBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.KIBIBYTES_PER_SECOND""" -DATA_RATE_MEBIBYTES_PER_SECOND: Final = "MiB/s" +_DEPRECATED_DATA_RATE_MEBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.MEBIBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.MEBIBYTES_PER_SECOND""" -DATA_RATE_GIBIBYTES_PER_SECOND: Final = "GiB/s" +_DEPRECATED_DATA_RATE_GIBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfDataRate.GIBIBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.GIBIBYTES_PER_SECOND""" @@ -1104,6 +1504,11 @@ class UnitOfDataRate(StrEnum): SERVICE_STOP_COVER_TILT: Final = "stop_cover_tilt" SERVICE_TOGGLE_COVER_TILT: Final = "toggle_cover_tilt" +SERVICE_CLOSE_VALVE: Final = "close_valve" +SERVICE_OPEN_VALVE: Final = "open_valve" +SERVICE_SET_VALVE_POSITION: Final = "set_valve_position" +SERVICE_STOP_VALVE: Final = "stop_valve" + SERVICE_SELECT_OPTION: Final = "select_option" # #### API / REMOTE #### @@ -1161,14 +1566,32 @@ class UnitOfDataRate(StrEnum): # cloud, alexa, or google_home components CLOUD_NEVER_EXPOSED_ENTITIES: Final[list[str]] = ["group.all_locks"] + +class EntityCategory(StrEnum): + """Category of an entity. + + An entity with a category will: + - Not be exposed to cloud, Alexa, or Google Assistant components + - Not be included in indirect service calls to devices or areas + """ + + # Config: An entity which allows changing the configuration of a device. + CONFIG = "config" + + # Diagnostic: An entity exposing some configuration parameter, + # or diagnostics of a device. + DIAGNOSTIC = "diagnostic" + + # ENTITY_CATEGOR* below are deprecated as of 2021.12 # use the EntityCategory enum instead. -ENTITY_CATEGORY_CONFIG: Final = "config" -ENTITY_CATEGORY_DIAGNOSTIC: Final = "diagnostic" -ENTITY_CATEGORIES: Final[list[str]] = [ - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, -] +_DEPRECATED_ENTITY_CATEGORY_CONFIG: Final = DeprecatedConstantEnum( + EntityCategory.CONFIG, "2025.1" +) +_DEPRECATED_ENTITY_CATEGORY_DIAGNOSTIC: Final = DeprecatedConstantEnum( + EntityCategory.DIAGNOSTIC, "2025.1" +) +ENTITY_CATEGORIES: Final[list[str]] = [cls.value for cls in EntityCategory] # The ID of the Home Assistant Media Player Cast App CAST_APP_ID_HOMEASSISTANT_MEDIA: Final = "B45F4572" @@ -1184,19 +1607,3 @@ class UnitOfDataRate(StrEnum): FORMAT_DATE: Final = "%Y-%m-%d" FORMAT_TIME: Final = "%H:%M:%S" FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}" - - -class EntityCategory(StrEnum): - """Category of an entity. - - An entity with a category will: - - Not be exposed to cloud, Alexa, or Google Assistant components - - Not be included in indirect service calls to devices or areas - """ - - # Config: An entity which allows changing the configuration of a device. - CONFIG = "config" - - # Diagnostic: An entity exposing some configuration parameter, - # or diagnostics of a device. - DIAGNOSTIC = "diagnostic" diff --git a/homeassistant/core.py b/homeassistant/core.py index d174786d968b94..4fdaa662e71665 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -18,6 +18,7 @@ ) import concurrent.futures from contextlib import suppress +from dataclasses import dataclass import datetime import enum import functools @@ -66,10 +67,10 @@ EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_STATE_CHANGED, - LENGTH_METERS, MATCH_ALL, MAX_LENGTH_EVENT_EVENT_TYPE, MAX_LENGTH_STATE_STATE, + UnitOfLength, __version__, ) from .exceptions import ( @@ -80,7 +81,11 @@ ServiceNotFound, Unauthorized, ) -from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior +from .helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from .helpers.json import json_dumps from .util import dt as dt_util, location from .util.async_ import ( @@ -91,7 +96,7 @@ 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, @@ -108,12 +113,12 @@ from .helpers.entity import StateInfo -STAGE_1_SHUTDOWN_TIMEOUT = 100 -STAGE_2_SHUTDOWN_TIMEOUT = 60 -STAGE_3_SHUTDOWN_TIMEOUT = 30 +STOPPING_STAGE_SHUTDOWN_TIMEOUT = 20 +STOP_STAGE_SHUTDOWN_TIMEOUT = 100 +FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT = 60 +CLOSE_STAGE_SHUTDOWN_TIMEOUT = 30 block_async_io.enable() -restore_original_aiohttp_cancel_behavior() _T = TypeVar("_T") _R = TypeVar("_R") @@ -147,9 +152,17 @@ class ConfigSource(enum.StrEnum): # SOURCE_* are deprecated as of Home Assistant 2022.2, use ConfigSource instead -SOURCE_DISCOVERED = ConfigSource.DISCOVERED.value -SOURCE_STORAGE = ConfigSource.STORAGE.value -SOURCE_YAML = ConfigSource.YAML.value +_DEPRECATED_SOURCE_DISCOVERED = DeprecatedConstantEnum( + ConfigSource.DISCOVERED, "2025.1" +) +_DEPRECATED_SOURCE_STORAGE = DeprecatedConstantEnum(ConfigSource.STORAGE, "2025.1") +_DEPRECATED_SOURCE_YAML = DeprecatedConstantEnum(ConfigSource.YAML, "2025.1") + + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = functools.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = functools.partial(dir_with_deprecated_constants, module_globals=globals()) + # How long to wait until things that run on startup have to finish. TIMEOUT_EVENT_START = 15 @@ -301,6 +314,14 @@ def __repr__(self) -> str: return f"" +@dataclass(frozen=True) +class HassJobWithArgs: + """Container for a HassJob and arguments.""" + + job: HassJob[..., Coroutine[Any, Any, Any] | Any] + args: Iterable[Any] + + def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: """Determine the job type from the callable.""" # Check for partials to properly determine if coroutine function @@ -372,6 +393,7 @@ def __init__(self, config_dir: str) -> None: # Timeout handler for Core/Helper namespace self.timeout: TimeoutManager = TimeoutManager() self._stop_future: concurrent.futures.Future[None] | None = None + self._shutdown_jobs: list[HassJobWithArgs] = [] @property def is_running(self) -> bool: @@ -562,13 +584,13 @@ def async_add_hass_job( # if TYPE_CHECKING to avoid the overhead of constructing # the type used for the cast. For history see: # https://github.com/home-assistant/core/pull/71960 - if hassjob.job_type == HassJobType.Coroutinefunction: + if hassjob.job_type is HassJobType.Coroutinefunction: if TYPE_CHECKING: hassjob.target = cast( Callable[..., Coroutine[Any, Any, _R]], hassjob.target ) task = self.loop.create_task(hassjob.target(*args), name=hassjob.name) - elif hassjob.job_type == HassJobType.Callback: + elif hassjob.job_type is HassJobType.Callback: if TYPE_CHECKING: hassjob.target = cast(Callable[..., _R], hassjob.target) self.loop.call_soon(hassjob.target, *args) @@ -667,7 +689,7 @@ def async_run_hass_job( # if TYPE_CHECKING to avoid the overhead of constructing # the type used for the cast. For history see: # https://github.com/home-assistant/core/pull/71960 - if hassjob.job_type == HassJobType.Callback: + if hassjob.job_type is HassJobType.Callback: if TYPE_CHECKING: hassjob.target = cast(Callable[..., _R], hassjob.target) hassjob.target(*args) @@ -768,6 +790,42 @@ async def _await_and_log_pending( for task in pending: _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any]], *args: Any + ) -> CALLBACK_TYPE: + ... + + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + ... + + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + """Add a HassJob which will be executed on shutdown. + + This method must be run in the event loop. + + hassjob: HassJob + args: parameters for method to call. + + Returns function to remove the job. + """ + job_with_args = HassJobWithArgs(hassjob, args) + self._shutdown_jobs.append(job_with_args) + + @callback + def remove_job() -> None: + self._shutdown_jobs.remove(job_with_args) + + return remove_job + def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" if self.state == CoreState.not_running: # just ignore @@ -801,6 +859,26 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: "Stopping Home Assistant before startup has completed may fail" ) + # Stage 1 - Run shutdown jobs + try: + async with self.timeout.async_timeout(STOPPING_STAGE_SHUTDOWN_TIMEOUT): + tasks: list[asyncio.Future[Any]] = [] + for job in self._shutdown_jobs: + task_or_none = self.async_run_hass_job(job.job, *job.args) + if not task_or_none: + continue + tasks.append(task_or_none) + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown jobs to complete, the shutdown will" + " continue" + ) + self._async_log_running_tasks("run shutdown jobs") + + # Stage 2 - Stop integrations + # Keep holding the reference to the tasks but do not allow them # to block shutdown. Only tasks created after this point will # be waited for. @@ -818,33 +896,32 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: self.exit_code = exit_code - # stage 1 self.state = CoreState.stopping self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) try: - async with self.timeout.async_timeout(STAGE_1_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 1 to complete, the shutdown will" + "Timed out waiting for integrations to stop, the shutdown will" " continue" ) - self._async_log_running_tasks(1) + self._async_log_running_tasks("stop integrations") - # stage 2 + # Stage 3 - Final write self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) try: - async with self.timeout.async_timeout(STAGE_2_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 2 to complete, the shutdown will" + "Timed out waiting for final writes to complete, the shutdown will" " continue" ) - self._async_log_running_tasks(2) + self._async_log_running_tasks("final write") - # stage 3 + # Stage 4 - Close self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) @@ -858,12 +935,12 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: # were awaiting another task continue _LOGGER.warning( - "Task %s was still running after stage 2 shutdown; " + "Task %s was still running after final writes shutdown stage; " "Integrations should cancel non-critical tasks when receiving " "the stop event to prevent delaying shutdown", task, ) - task.cancel("Home Assistant stage 2 shutdown") + task.cancel("Home Assistant final writes shutdown stage") try: async with asyncio.timeout(0.1): await task @@ -872,10 +949,12 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: except asyncio.TimeoutError: # Task may be shielded from cancellation. _LOGGER.exception( - "Task %s could not be canceled during stage 3 shutdown", task + "Task %s could not be canceled during final shutdown stage", task + ) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception( + "Task %s error during final shutdown stage: %s", task, exc ) - except Exception as ex: # pylint: disable=broad-except - _LOGGER.exception("Task %s error during stage 3 shutdown: %s", task, ex) # Prevent run_callback_threadsafe from scheduling any additional # callbacks in the event loop as callbacks created on the futures @@ -885,14 +964,14 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: shutdown_run_callback_threadsafe(self.loop) try: - async with self.timeout.async_timeout(STAGE_3_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(CLOSE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 3 to complete, the shutdown will" + "Timed out waiting for close event to be processed, the shutdown will" " continue" ) - self._async_log_running_tasks(3) + self._async_log_running_tasks("close") self.state = CoreState.stopped @@ -912,10 +991,10 @@ def _cancel_cancellable_timers(self) -> None: ): handle.cancel() - def _async_log_running_tasks(self, stage: int) -> None: + def _async_log_running_tasks(self, stage: str) -> None: """Log all running tasks.""" for task in self._tasks: - _LOGGER.warning("Shutdown stage %s: still running: %s", stage, task) + _LOGGER.warning("Shutdown stage '%s': still running: %s", stage, task) class Context: @@ -930,7 +1009,7 @@ def __init__( 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 @@ -1310,7 +1389,13 @@ def __init__( self.entity_id = entity_id self.state = state - self.attributes = ReadOnlyDict(attributes or {}) + # State only creates and expects a ReadOnlyDict so + # there is no need to check for subclassing with + # isinstance here so we can use the faster type check. + if type(attributes) is not ReadOnlyDict: # noqa: E721 + self.attributes = ReadOnlyDict(attributes or {}) + else: + self.attributes = attributes self.last_updated = last_updated or dt_util.utcnow() self.last_changed = last_changed or self.last_updated self.context = context or Context() @@ -1729,6 +1814,11 @@ def async_set( else: now = dt_util.utcnow() + if same_attr: + if TYPE_CHECKING: + assert old_state is not None + attributes = old_state.attributes + state = State( entity_id, new_state, @@ -1871,13 +1961,20 @@ def register( Coroutine[Any, Any, ServiceResponse] | ServiceResponse | None, ], schema: vol.Schema | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Register a service. Schema is called to coerce and validate the service data. """ run_callback_threadsafe( - self._hass.loop, self.async_register, domain, service, service_func, schema + self._hass.loop, + self.async_register, + domain, + service, + service_func, + schema, + supports_response, ).result() @callback @@ -2098,11 +2195,11 @@ async def _execute_service( """Execute a service.""" job = handler.job target = job.target - if job.job_type == HassJobType.Coroutinefunction: + if job.job_type is HassJobType.Coroutinefunction: if TYPE_CHECKING: target = cast(Callable[..., Coroutine[Any, Any, _R]], target) return await target(service_call) - if job.job_type == HassJobType.Callback: + if job.job_type is HassJobType.Callback: if TYPE_CHECKING: target = cast(Callable[..., _R], target) return target(service_call) @@ -2176,7 +2273,8 @@ def distance(self, lat: float, lon: float) -> float | None: Async friendly. """ return self.units.length( - location.distance(self.latitude, self.longitude, lat, lon), LENGTH_METERS + location.distance(self.latitude, self.longitude, lat, lon), + UnitOfLength.METERS, ) def path(self, *path: str) -> str: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e0ea195a3ffbed..5c9c0ff1ce4d7f 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -6,6 +6,7 @@ import copy from dataclasses import dataclass from enum import StrEnum +from functools import partial import logging from types import MappingProxyType from typing import Any, Required, TypedDict @@ -14,6 +15,11 @@ from .core import HomeAssistant, callback from .exceptions import HomeAssistantError +from .helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from .helpers.frame import report from .util import uuid as uuid_util @@ -34,18 +40,41 @@ class FlowResultType(StrEnum): # RESULT_TYPE_* is deprecated, to be removed in 2022.9 -RESULT_TYPE_FORM = "form" -RESULT_TYPE_CREATE_ENTRY = "create_entry" -RESULT_TYPE_ABORT = "abort" -RESULT_TYPE_EXTERNAL_STEP = "external" -RESULT_TYPE_EXTERNAL_STEP_DONE = "external_done" -RESULT_TYPE_SHOW_PROGRESS = "progress" -RESULT_TYPE_SHOW_PROGRESS_DONE = "progress_done" -RESULT_TYPE_MENU = "menu" +_DEPRECATED_RESULT_TYPE_FORM = DeprecatedConstantEnum(FlowResultType.FORM, "2025.1") +_DEPRECATED_RESULT_TYPE_CREATE_ENTRY = DeprecatedConstantEnum( + FlowResultType.CREATE_ENTRY, "2025.1" +) +_DEPRECATED_RESULT_TYPE_ABORT = DeprecatedConstantEnum(FlowResultType.ABORT, "2025.1") +_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP = DeprecatedConstantEnum( + FlowResultType.EXTERNAL_STEP, "2025.1" +) +_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP_DONE = DeprecatedConstantEnum( + FlowResultType.EXTERNAL_STEP_DONE, "2025.1" +) +_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS = DeprecatedConstantEnum( + FlowResultType.SHOW_PROGRESS, "2025.1" +) +_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS_DONE = DeprecatedConstantEnum( + FlowResultType.SHOW_PROGRESS_DONE, "2025.1" +) +_DEPRECATED_RESULT_TYPE_MENU = DeprecatedConstantEnum(FlowResultType.MENU, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) # Event that is fired when a flow is progressed via external or progress source. EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" +FLOW_NOT_COMPLETE_STEPS = { + FlowResultType.FORM, + FlowResultType.EXTERNAL_STEP, + FlowResultType.EXTERNAL_STEP_DONE, + FlowResultType.SHOW_PROGRESS, + FlowResultType.SHOW_PROGRESS_DONE, + FlowResultType.MENU, +} + @dataclass(slots=True) class BaseServiceInfo: @@ -94,6 +123,7 @@ class FlowResult(TypedDict, total=False): handler: Required[str] last_step: bool | None menu_options: list[str] | dict[str, str] + minor_version: int options: Mapping[str, Any] preview: str | None progress_action: str @@ -406,14 +436,7 @@ async def _async_handle_step( error_if_core=False, ) - if result["type"] in ( - FlowResultType.FORM, - FlowResultType.EXTERNAL_STEP, - FlowResultType.EXTERNAL_STEP_DONE, - FlowResultType.SHOW_PROGRESS, - FlowResultType.SHOW_PROGRESS_DONE, - FlowResultType.MENU, - ): + if result["type"] in FLOW_NOT_COMPLETE_STEPS: self._raise_if_step_does_not_exist(flow, result["step_id"]) flow.cur_step = result return result @@ -470,6 +493,7 @@ class FlowHandler: # Set by developer VERSION = 1 + MINOR_VERSION = 1 @property def source(self) -> str | None: @@ -549,6 +573,7 @@ def async_create_entry( """Finish flow.""" flow_result = FlowResult( version=self.VERSION, + minor_version=self.MINOR_VERSION, type=FlowResultType.CREATE_ENTRY, flow_id=self.flow_id, handler=self.handler, diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 262b0e338ff770..8d5e2bbde950ab 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -26,6 +26,31 @@ def __init__( 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.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 16f0e48e4ee35a..254a3ad0df3c05 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -14,6 +14,7 @@ "template", "threshold", "tod", + "trend", "utility_meter", ], "integration": [ @@ -46,6 +47,7 @@ "androidtv_remote", "anova", "anthemav", + "aosmith", "apcupsd", "apple_tv", "aranet", @@ -65,6 +67,7 @@ "balboa", "blebox", "blink", + "blue_current", "bluemaestro", "bluetooth", "bmw_connected_drive", @@ -81,6 +84,7 @@ "caldav", "canary", "cast", + "ccm15", "cert_expiry", "cloudflare", "co2signal", @@ -95,6 +99,7 @@ "deconz", "deluge", "denonavr", + "devialet", "devolo_home_control", "devolo_home_network", "dexcom", @@ -109,6 +114,7 @@ "doorbird", "dormakaba_dkey", "dremel_3d_printer", + "drop_connect", "dsmr", "dsmr_reader", "dunehd", @@ -141,12 +147,14 @@ "evil_genius_labs", "ezviz", "faa_delays", + "fastdotcom", "fibaro", "filesize", "fireservicerota", "fitbit", "fivem", "fjaraskupan", + "flexit_bacnet", "flick_electric", "flipr", "flo", @@ -198,6 +206,7 @@ "hisense_aehw4a1", "hive", "hlk_sw16", + "holiday", "home_connect", "home_plus_control", "homeassistant_sky_connect", @@ -244,7 +253,6 @@ "kmtronic", "knx", "kodi", - "komfovent", "konnected", "kostal_plenticore", "kraken", @@ -261,6 +269,7 @@ "lidarr", "life360", "lifx", + "linear_garage_door", "litejet", "litterrobot", "livisi", @@ -299,6 +308,7 @@ "mopeka", "motion_blinds", "motioneye", + "motionmount", "mqtt", "mullvad", "mutesync", @@ -310,6 +320,7 @@ "nest", "netatmo", "netgear", + "netgear_lte", "nexia", "nextbus", "nextcloud", @@ -344,7 +355,9 @@ "openweathermap", "opower", "oralb", + "osoenergy", "otbr", + "ourgroceries", "overkiz", "ovo_energy", "owntracks", @@ -352,9 +365,11 @@ "panasonic_viera", "peco", "pegel_online", + "permobil", "philips_js", "pi_hole", "picnic", + "ping", "plaato", "plex", "plugwise", @@ -388,6 +403,7 @@ "rapt_ble", "rdw", "recollect_waste", + "refoss", "renault", "renson", "reolink", @@ -461,9 +477,13 @@ "steamist", "stookalert", "stookwijzer", + "streamlabswater", "subaru", + "suez_water", "sun", + "sunweg", "surepetcare", + "swiss_public_transport", "switchbee", "switchbot", "switchbot_cloud", @@ -472,14 +492,18 @@ "syncthru", "synology_dsm", "system_bridge", + "systemmonitor", "tado", "tailscale", + "tailwind", "tami4", "tankerkoenig", "tasmota", "tautulli", + "tedee", "tellduslive", "tesla_wall_connector", + "tessie", "thermobeacon", "thermopro", "thread", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 63c7cd84303d7f..33d069c5663ed9 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -109,6 +109,10 @@ "domain": "broadlink", "macaddress": "EC0BAE*", }, + { + "domain": "broadlink", + "macaddress": "780F77*", + }, { "domain": "dlink", "hostname": "dsp-w215", @@ -567,6 +571,10 @@ "domain": "tado", "hostname": "tado*", }, + { + "domain": "tailwind", + "registered_devices": True, + }, { "domain": "tesla_wall_connector", "hostname": "teslawallconnector_*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7680463cbd2a79..6df3dc5cbd6415 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -65,6 +65,16 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "aep_ohio": { + "name": "AEP Ohio", + "integration_type": "virtual", + "supported_by": "opower" + }, + "aep_texas": { + "name": "AEP Texas", + "integration_type": "virtual", + "supported_by": "opower" + }, "aftership": { "name": "AfterShip", "integration_type": "hub", @@ -286,6 +296,12 @@ "integration_type": "virtual", "supported_by": "energyzero" }, + "aosmith": { + "name": "A. O. Smith", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "apache_kafka": { "name": "Apache Kafka", "integration_type": "hub", @@ -298,6 +314,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "appalachianpower": { + "name": "Appalachian Power", + "integration_type": "virtual", + "supported_by": "opower" + }, "apple": { "name": "Apple", "integrations": { @@ -629,6 +650,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "blue_current": { + "name": "Blue Current", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "bluemaestro": { "name": "BlueMaestro", "integration_type": "hub", @@ -774,6 +801,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ccm15": { + "name": "Midea ccm15 AC Controller", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "cert_expiry": { "integration_type": "hub", "config_flow": true, @@ -1067,6 +1100,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", @@ -1226,6 +1265,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "drop_connect": { + "name": "DROP", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "dsmr": { "name": "DSMR Slimme Meter", "integration_type": "hub", @@ -1552,12 +1597,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 +1695,7 @@ "fastdotcom": { "name": "Fast.com", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "feedreader": { @@ -1718,7 +1757,7 @@ }, "fints": { "name": "FinTS", - "integration_type": "hub", + "integration_type": "service", "config_flow": false, "iot_class": "cloud_polling" }, @@ -1766,9 +1805,20 @@ }, "flexit": { "name": "Flexit", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" + "integrations": { + "flexit": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_polling", + "name": "Flexit" + }, + "flexit_bacnet": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "Flexit Nordic (BACnet)" + } + } }, "flexom": { "name": "Bouygues Flexom", @@ -1930,6 +1980,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "fujitsu_anywair": { + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" + }, "fully_kiosk": { "name": "Fully Kiosk Browser", "integration_type": "hub", @@ -2397,6 +2452,11 @@ "config_flow": true, "iot_class": "local_push" }, + "holiday": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "home_connect": { "name": "Home Connect", "integration_type": "hub", @@ -2626,6 +2686,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "indianamichiganpower": { + "name": "Indiana Michigan Power", + "integration_type": "virtual", + "supported_by": "opower" + }, "influxdb": { "name": "InfluxDB", "integration_type": "hub", @@ -2827,6 +2892,11 @@ "config_flow": true, "iot_class": "local_push" }, + "kentuckypower": { + "name": "Kentucky Power", + "integration_type": "virtual", + "supported_by": "opower" + }, "keyboard": { "name": "Keyboard", "integration_type": "hub", @@ -2881,12 +2951,6 @@ "config_flow": true, "iot_class": "local_push" }, - "komfovent": { - "name": "Komfovent", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "konnected": { "name": "Konnected.io", "integration_type": "hub", @@ -3053,6 +3117,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", @@ -3560,6 +3630,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "motionmount": { + "name": "Vogel's MotionMount", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "mpd": { "name": "Music Player Daemon (MPD)", "integration_type": "hub", @@ -3714,7 +3790,7 @@ }, "netgear_lte": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "NETGEAR LTE" } @@ -4128,6 +4204,12 @@ "config_flow": false, "iot_class": "local_push" }, + "osoenergy": { + "name": "OSO Energy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "osramlightify": { "name": "Osramlightify", "integration_type": "hub", @@ -4146,11 +4228,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 +4324,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 +4385,7 @@ "ping": { "name": "Ping (ICMP)", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "pioneer": { @@ -4430,6 +4524,11 @@ "integration_type": "virtual", "supported_by": "opower" }, + "psoklahoma": { + "name": "Public Service Company of Oklahoma (PSO)", + "integration_type": "virtual", + "supported_by": "opower" + }, "pulseaudio_loopback": { "name": "PulseAudio Loopback", "integration_type": "hub", @@ -4664,6 +4763,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "refoss": { + "name": "Refoss", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "rejseplanen": { "name": "Rejseplanen", "integration_type": "hub", @@ -4919,6 +5024,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "scl": { + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" + }, "scrape": { "name": "Scrape", "integration_type": "hub", @@ -5474,7 +5584,7 @@ "streamlabswater": { "name": "StreamLabs", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "subaru": { @@ -5486,7 +5596,7 @@ "suez_water": { "name": "Suez Water", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "sun": { @@ -5494,6 +5604,12 @@ "config_flow": true, "iot_class": "calculated" }, + "sunweg": { + "name": "Sun WEG", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "supervisord": { "name": "Supervisord", "integration_type": "hub", @@ -5512,6 +5628,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "swepco": { + "name": "Southwestern Electric Power Company (SWEPCO)", + "integration_type": "virtual", + "supported_by": "opower" + }, "swiss_hydrological_data": { "name": "Swiss Hydrological Data", "integration_type": "hub", @@ -5521,7 +5642,7 @@ "swiss_public_transport": { "name": "Swiss public transport", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "swisscom": { @@ -5609,7 +5730,7 @@ "systemmonitor": { "name": "System Monitor", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "tado": { @@ -5628,6 +5749,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "tailwind": { + "name": "Tailwind", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "tami4": { "name": "Tami4 Edge / Edge+", "integration_type": "hub", @@ -5676,6 +5803,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "tedee": { + "name": "Tedee", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "telegram": { "name": "Telegram", "integrations": { @@ -5745,6 +5878,12 @@ } } }, + "tessie": { + "name": "Tessie", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tfiac": { "name": "Tfiac", "integration_type": "hub", @@ -5979,12 +6118,6 @@ "config_flow": false, "iot_class": "cloud_polling" }, - "trend": { - "name": "Trend", - "integration_type": "hub", - "config_flow": false, - "iot_class": "calculated" - }, "tuya": { "name": "Tuya", "integration_type": "hub", @@ -6098,7 +6231,7 @@ "iot_class": "cloud_polling" }, "universal": { - "name": "Universal Media Player", + "name": "Universal media player", "integration_type": "hub", "config_flow": false, "iot_class": "calculated" @@ -6648,7 +6781,7 @@ "iot_class": "local_polling" }, "zamg": { - "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)", + "name": "GeoSphere Austria", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" @@ -6810,6 +6943,12 @@ "config_flow": true, "iot_class": "calculated" }, + "trend": { + "name": "Trend", + "integration_type": "helper", + "config_flow": true, + "iot_class": "calculated" + }, "utility_meter": { "integration_type": "helper", "config_flow": true, @@ -6831,6 +6970,7 @@ "google_travel_time", "group", "growatt_server", + "holiday", "homekit_controller", "input_boolean", "input_button", diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index 69abf7c64fe588..0c456774e4d7f8 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -4,6 +4,9 @@ """ MQTT = { + "drop_connect": [ + "drop_connect/discovery/#", + ], "dsmr_reader": [ "dsmr/#", ], diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index f58936caf8de74..2fdd032c2dd216 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -81,6 +81,12 @@ "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 485d16e46e70e8..fea1d4ec88927c 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -52,7 +52,7 @@ "always_discover": True, "domain": "hive", }, - "Healty Home Coach": { + "Healthy Home Coach": { "always_discover": True, "domain": "netatmo", }, @@ -116,6 +116,10 @@ "always_discover": True, "domain": "lifx", }, + "LIFX Neon": { + "always_discover": True, + "domain": "lifx", + }, "LIFX Nightvision": { "always_discover": True, "domain": "lifx", @@ -128,6 +132,10 @@ "always_discover": True, "domain": "lifx", }, + "LIFX String": { + "always_discover": True, + "domain": "lifx", + }, "LIFX Tile": { "always_discover": True, "domain": "lifx", @@ -356,6 +364,11 @@ "domain": "forked_daapd", }, ], + "_devialet-http._tcp.local.": [ + { + "domain": "devialet", + }, + ], "_dkapi._tcp.local.": [ { "domain": "daikin", @@ -481,6 +494,12 @@ "vendor": "synology*", }, }, + { + "domain": "tailwind", + "properties": { + "vendor": "tailwind", + }, + }, ], "_hue._tcp.local.": [ { @@ -508,6 +527,12 @@ "name": "gateway*", }, ], + "_kizboxdev._tcp.local.": [ + { + "domain": "overkiz", + "name": "gateway*", + }, + ], "_lookin._tcp.local.": [ { "domain": "lookin", @@ -680,6 +705,11 @@ "domain": "apple_tv", }, ], + "_tvm._tcp.local.": [ + { + "domain": "motionmount", + }, + ], "_uzg-01._tcp.local.": [ { "domain": "zha", @@ -696,6 +726,11 @@ "domain": "wled", }, ], + "_wyoming._tcp.local.": [ + { + "domain": "wyoming", + }, + ], "_xbmc-jsonrpc-h._tcp.local.": [ { "domain": "kodi", diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index c9acdf0d712eb8..52197e8349548d 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -2,11 +2,8 @@ from __future__ import annotations from collections.abc import Iterable, Sequence -import re from typing import TYPE_CHECKING -from homeassistant.const import CONF_PLATFORM - if TYPE_CHECKING: from .typing import ConfigType @@ -19,22 +16,23 @@ def config_per_platform( For example, will find 'switch', 'switch 2', 'switch 3', .. etc Async friendly. """ - for config_key in extract_domain_configs(config, domain): - if not (platform_config := config[config_key]): - continue + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config + + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning - if not isinstance(platform_config, list): - platform_config = [platform_config] + _print_deprecation_warning( + config_per_platform, + "config.config_per_platform", + "function", + "called", + "2024.6", + ) + return ha_config.config_per_platform(config, domain) - item: ConfigType - platform: str | None - for item in platform_config: - try: - platform = item.get(CONF_PLATFORM) - except AttributeError: - platform = None - yield platform, item +config_per_platform.__name__ = "helpers.config_per_platform" def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: @@ -42,5 +40,20 @@ def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: Async friendly. """ - pattern = re.compile(rf"^{domain}(| .+)$") - return [key for key in config if pattern.match(key)] + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config + + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning + + _print_deprecation_warning( + extract_domain_configs, + "config.extract_domain_configs", + "function", + "called", + "2024.6", + ) + return ha_config.extract_domain_configs(config, domain) + + +extract_domain_configs.__name__ = "helpers.extract_domain_configs" diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index b8d810d899b7b8..74527a5922f405 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -58,19 +58,6 @@ 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 6e281b659fe699..00000000000000 --- a/homeassistant/helpers/aiohttp_compat.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Helper to restore old aiohttp behavior.""" -from __future__ import annotations - -from aiohttp import web, 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] - - -def enable_compression(response: web.Response) -> None: - """Enable compression on the response.""" - # - # Set _zlib_executor_size in the constructor once support for - # aiohttp < 3.9.0 is dropped - # - # We want large zlib payloads to be compressed in the executor - # to avoid blocking the event loop. - # - # 32KiB was chosen based on testing in production. - # aiohttp will generate a warning for payloads larger than 1MiB - # - response._zlib_executor_size = 32768 # pylint: disable=protected-access - response.enable_compression() diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index c333bab782bf16..1c8efadfdc5dba 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -15,9 +15,10 @@ 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, ) @@ -30,6 +31,7 @@ ) import homeassistant.util.yaml.loader as yaml_loader +from . import config_validation as cv from .typing import ConfigType @@ -92,24 +94,33 @@ 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_warning(message, domain, pack_config) - def _comp_error(ex: Exception, domain: str, config: ConfigType) -> None: - """Handle errors from components: async_log_exception.""" + 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( - _format_config_error(ex, domain, config)[0], domain, config - ) + result.add_error(message, domain, config_to_attach) else: - result.add_warning( - _format_config_error(ex, domain, config)[0], domain, config - ) + result.add_warning(message, domain, config_to_attach) async def _get_integration( hass: HomeAssistant, domain: str @@ -152,7 +163,9 @@ async def _get_integration( result[CONF_CORE] = core_config except vol.Invalid as err: result.add_error( - _format_config_error(err, CONF_CORE, core_config)[0], CONF_CORE, core_config + format_schema_error(hass, err, CONF_CORE, core_config), + CONF_CORE, + core_config, ) core_config = {} @@ -163,7 +176,7 @@ async def _get_integration( core_config.pop(CONF_PACKAGES, None) # Filter out repeating config sections - components = {key.partition(" ")[0] for key in config} + components = {cv.domain_key(key) for key in config} frontend_dependencies: set[str] = set() if "frontend" in components or "default_config" in components: @@ -204,7 +217,7 @@ async def _get_integration( )[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( @@ -220,12 +233,12 @@ async def _get_integration( 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( @@ -243,7 +256,7 @@ async def _get_integration( try: p_validated = component_platform_schema(p_config) except vol.Invalid as ex: - _comp_error(ex, domain, p_config) + _comp_error(ex, domain, p_config, p_config) continue # Not all platform components follow same pattern for platforms @@ -264,13 +277,17 @@ async def _get_integration( # 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"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue except ( RequirementsNotFound, ImportError, ) as ex: - result.add_warning(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue # Validate platform specific schema @@ -279,7 +296,7 @@ async def _get_integration( try: p_validated = platform_schema(p_validated) except vol.Invalid as ex: - _comp_error(ex, f"{domain}.{p_name}", p_config) + _comp_error(ex, f"{domain}.{p_name}", p_config, p_config) continue platforms.append(p_validated) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 18445ba07895bc..e4b62dd679ddd4 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -99,6 +99,7 @@ from homeassistant.generated.languages import LANGUAGES from homeassistant.util import raise_if_invalid_path, slugify as util_slugify import homeassistant.util.dt as dt_util +from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper @@ -350,6 +351,30 @@ def entity_ids_or_uuids(value: str | list) -> list[str]: ) +def domain_key(config_key: Any) -> str: + """Validate a top level config key with an optional label and return the domain. + + A domain is separated from a label by one or more spaces, empty labels are not + allowed. + + Examples: + 'hue' returns 'hue' + 'hue 1' returns 'hue' + 'hue 1' returns 'hue' + 'hue ' raises + 'hue ' raises + """ + if not isinstance(config_key, str): + raise vol.Invalid("invalid domain", path=[config_key]) + + parts = config_key.partition(" ") + _domain = parts[0] if parts[2].strip(" ") else config_key + if not _domain or _domain.strip(" ") != _domain: + raise vol.Invalid("invalid domain", path=[config_key]) + + return _domain + + def entity_domain(domain: str | list[str]) -> Callable[[Any], str]: """Validate that entity belong to domain.""" ent_domain = entities_domain(domain) @@ -581,7 +606,11 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if type(value) is str: # noqa: E721 + if ( + type(value) is str # noqa: E721 + or type(value) is NodeStrClass # noqa: E721 + or isinstance(value, str) + ): return value if isinstance(value, template_helper.ResultWrapper): diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index c499dd0b6cd513..72b26e90b84275 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -3,16 +3,11 @@ from collections.abc import Callable from contextlib import suppress +from enum import Enum import functools import inspect import logging -from typing import Any, ParamSpec, TypeVar - -from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import async_suggest_report_issue - -from .frame import MissingIntegrationFrame, get_integration_frame +from typing import Any, NamedTuple, ParamSpec, TypeVar _ObjectT = TypeVar("_ObjectT", bound=object) _R = TypeVar("_R") @@ -97,9 +92,13 @@ def get_deprecated( def deprecated_class( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> 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 +106,9 @@ def deprecated_decorator(cls: Callable[_P, _R]) -> Callable[_P, _R]: @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", breaks_in_ha_version + ) return cls(*args, **kwargs) return deprecated_cls @@ -116,9 +117,13 @@ def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: def deprecated_function( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> 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 +131,9 @@ def deprecated_decorator(func: Callable[_P, _R]) -> Callable[_P, _R]: @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", breaks_in_ha_version + ) return func(*args, **kwargs) return deprecated_func @@ -134,10 +141,58 @@ def deprecated_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: return deprecated_decorator -def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: - logger = logging.getLogger(obj.__module__) +def _print_deprecation_warning( + obj: Any, + replacement: str, + description: str, + verb: str, + breaks_in_ha_version: str | None, +) -> None: + _print_deprecation_warning_internal( + obj.__name__, + obj.__module__, + replacement, + description, + verb, + breaks_in_ha_version, + log_when_no_integration_is_found=True, + ) + + +def _print_deprecation_warning_internal( + obj_name: str, + module_name: str, + replacement: str, + description: str, + verb: str, + breaks_in_ha_version: str | None, + *, + log_when_no_integration_is_found: bool, +) -> None: + # pylint: disable=import-outside-toplevel + from homeassistant.core import HomeAssistant, async_get_hass + from homeassistant.exceptions import HomeAssistantError + from homeassistant.loader import async_suggest_report_issue + + from .frame import MissingIntegrationFrame, get_integration_frame + + logger = logging.getLogger(module_name) + if breaks_in_ha_version: + breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" + else: + breaks_in = "" try: integration_frame = get_integration_frame() + except MissingIntegrationFrame: + if log_when_no_integration_is_found: + logger.warning( + "%s is a deprecated %s%s. Use %s instead", + obj_name, + description, + breaks_in, + replacement, + ) + else: if integration_frame.custom_integration: hass: HomeAssistant | None = None with suppress(HomeAssistantError): @@ -149,27 +204,98 @@ 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%s. Use %s instead," " please %s" ), - obj.__name__, + obj_name, + verb, integration_frame.integration, description, + breaks_in, replacement, report_issue, ) else: logger.warning( - "%s was called from %s, this is a deprecated %s. Use %s instead", - obj.__name__, + "%s was %s from %s, this is a deprecated %s%s. Use %s instead", + obj_name, + verb, integration_frame.integration, description, + breaks_in, replacement, ) - except MissingIntegrationFrame: - logger.warning( - "%s is a deprecated %s. Use %s instead", - obj.__name__, - description, - replacement, + + +class DeprecatedConstant(NamedTuple): + """Deprecated constant.""" + + value: Any + replacement: str + breaks_in_ha_version: str | None + + +class DeprecatedConstantEnum(NamedTuple): + """Deprecated constant.""" + + enum: Enum + breaks_in_ha_version: str | None + + +_PREFIX_DEPRECATED = "_DEPRECATED_" + + +def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> Any: + """Check if the not found name is a deprecated constant. + + If it is, print a deprecation warning and return the value of the constant. + Otherwise raise AttributeError. + """ + module_name = module_globals.get("__name__") + logger = logging.getLogger(module_name) + value = replacement = None + if (deprecated_const := module_globals.get(_PREFIX_DEPRECATED + name)) is None: + raise AttributeError(f"Module {module_name!r} has no attribute {name!r}") + if isinstance(deprecated_const, DeprecatedConstant): + value = deprecated_const.value + replacement = deprecated_const.replacement + breaks_in_ha_version = deprecated_const.breaks_in_ha_version + elif isinstance(deprecated_const, DeprecatedConstantEnum): + value = deprecated_const.enum.value + replacement = ( + f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" + ) + breaks_in_ha_version = deprecated_const.breaks_in_ha_version + + if value is None or replacement is None: + msg = ( + f"Value of {_PREFIX_DEPRECATED}{name} is an instance of {type(deprecated_const)} " + "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" ) + + logger.debug(msg) + # PEP 562 -- Module __getattr__ and __dir__ + # specifies that __getattr__ should raise AttributeError if the attribute is not + # found. + # https://peps.python.org/pep-0562/#specification + raise AttributeError(msg) # noqa: TRY004 + + _print_deprecation_warning_internal( + name, + module_name or __name__, + replacement, + "constant", + "used", + breaks_in_ha_version, + log_when_no_integration_is_found=False, + ) + return value + + +def dir_with_deprecated_constants(module_globals: dict[str, Any]) -> list[str]: + """Return dir() with deprecated constants.""" + return list(module_globals) + [ + name.removeprefix(_PREFIX_DEPRECATED) + for name in module_globals + if name.startswith(_PREFIX_DEPRECATED) + ] diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9a26821faafd61..bd509cb47ec24d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -4,6 +4,7 @@ from collections import UserDict from collections.abc import Coroutine, ValuesView from enum import StrEnum +from functools import partial import logging import time from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast @@ -21,6 +22,11 @@ from . import storage from .debounce import Debouncer +from .deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from .frame import report from .json import JSON_DUMP, find_paths_unserializable_data from .typing import UNDEFINED, UndefinedType @@ -61,9 +67,17 @@ class DeviceEntryDisabler(StrEnum): # DISABLED_* are deprecated, to be removed in 2022.3 -DISABLED_CONFIG_ENTRY = DeviceEntryDisabler.CONFIG_ENTRY.value -DISABLED_INTEGRATION = DeviceEntryDisabler.INTEGRATION.value -DISABLED_USER = DeviceEntryDisabler.USER.value +_DEPRECATED_DISABLED_CONFIG_ENTRY = DeprecatedConstantEnum( + DeviceEntryDisabler.CONFIG_ENTRY, "2025.1" +) +_DEPRECATED_DISABLED_INTEGRATION = DeprecatedConstantEnum( + DeviceEntryDisabler.INTEGRATION, "2025.1" +) +_DEPRECATED_DISABLED_USER = DeprecatedConstantEnum(DeviceEntryDisabler.USER, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) class DeviceInfo(TypedDict, total=False): diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index e416d939914b69..07112226ecfd89 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -90,20 +90,22 @@ def dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, *args) +def _format_err(signal: str, target: Callable[..., Any], *args: Any) -> str: + """Format error message.""" + return "Exception in {} when dispatching '{}': {}".format( + # Functions wrapped in partial do not have a __name__ + getattr(target, "__name__", None) or str(target), + signal, + args, + ) + + def _generate_job( signal: str, target: Callable[..., Any] ) -> HassJob[..., None | Coroutine[Any, Any, None]]: """Generate a HassJob for a signal and target.""" return HassJob( - catch_log_exception( - target, - lambda *args: "Exception in {} when dispatching '{}': {}".format( - # Functions wrapped in partial do not have a __name__ - getattr(target, "__name__", None) or str(target), - signal, - args, - ), - ), + catch_log_exception(target, partial(_format_err, signal, target)), f"dispatcher {signal}", ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 7877ca0e6135db..ea0267b21dbc0e 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1,12 +1,13 @@ """An abstract class for entities.""" from __future__ import annotations -from abc import ABC +from abc import ABCMeta import asyncio -from collections.abc import Coroutine, Iterable, Mapping, MutableMapping -from dataclasses import dataclass +from collections import deque +from collections.abc import Callable, Coroutine, Iterable, Mapping, MutableMapping +import dataclasses from datetime import timedelta -from enum import Enum, auto +from enum import Enum, IntFlag, auto import functools as ft import logging import math @@ -25,7 +26,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config import DATA_CUSTOMIZE from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -43,7 +43,13 @@ STATE_UNKNOWN, EntityCategory, ) -from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + HomeAssistant, + callback, + get_release_channel, +) from homeassistant.exceptions import ( HomeAssistantError, InvalidStateError, @@ -51,6 +57,7 @@ ) from homeassistant.loader import async_suggest_report_issue, bind_hass from homeassistant.util import ensure_unique_string, slugify +from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed from . import device_registry as dr, entity_registry as er from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData @@ -61,8 +68,11 @@ from .typing import UNDEFINED, EventType, StateType, UndefinedType if TYPE_CHECKING: - from .entity_platform import EntityPlatform + from functools import cached_property + from .entity_platform import EntityPlatform +else: + from homeassistant.backports.functools import cached_property _T = TypeVar("_T") @@ -74,6 +84,11 @@ # epsilon to make the string representation readable FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) - 1 +# How many times per hour we allow capabilities to be updated before logging a warning +CAPABILITIES_UPDATE_LIMIT = 100 + +CONTEXT_RECENT_TIME = timedelta(seconds=5) # Time that a context is considered recent + @callback def async_setup(hass: HomeAssistant) -> None: @@ -218,8 +233,10 @@ class EntityPlatformState(Enum): REMOVED = auto() -@dataclass(slots=True) -class EntityDescription: +_SENTINEL = object() + + +class EntityDescription(metaclass=FrozenOrThawed, frozen_or_thawed=True): """A class that describes Home Assistant entities.""" # This is the key identifier for this entity @@ -234,10 +251,200 @@ class EntityDescription: has_entity_name: bool = False name: str | UndefinedType | None = UNDEFINED translation_key: str | None = None + translation_placeholders: Mapping[str, str] | None = None unit_of_measurement: str | None = None -class Entity(ABC): +@dataclasses.dataclass(frozen=True, slots=True) +class CalculatedState: + """Container with state and attributes. + + Returned by Entity._async_calculate_state. + """ + + state: str + # The union of all attributes, after overriding with entity registry settings + attributes: dict[str, Any] + # Capability attributes returned by the capability_attributes property + capability_attributes: Mapping[str, Any] | None + # Attributes which may be overridden by the entity registry + shadowed_attributes: Mapping[str, Any] + + +class CachedProperties(type): + """Metaclass which invalidates cached entity properties on write to _attr_. + + A class which has CachedProperties can optionally have a list of cached + properties, passed as cached_properties, which must be a set of strings. + - Each item in the cached_property set must be the name of a method decorated + with @cached_property + - For each item in the cached_property set, a property function with the + same name, prefixed with _attr_, will be created + - The property _attr_-property functions allow setting, getting and deleting + data, which will be stored in an attribute prefixed with __attr_ + - The _attr_-property setter will invalidate the @cached_property by calling + delattr on it + """ + + def __new__( + mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + cached_properties: set[str] | None = None, + **kwargs: Any, + ) -> Any: + """Start creating a new CachedProperties. + + Pop cached_properties and store it in the namespace. + """ + namespace["_CachedProperties__cached_properties"] = cached_properties or set() + return super().__new__(mcs, name, bases, namespace) + + def __init__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + **kwargs: Any, + ) -> None: + """Finish creating a new CachedProperties. + + Wrap _attr_ for cached properties in property objects. + """ + + def deleter(name: str) -> Callable[[Any], None]: + """Create a deleter for an _attr_ property.""" + private_attr_name = f"__attr_{name}" + + def _deleter(o: Any) -> None: + """Delete an _attr_ property. + + Does two things: + - Delete the __attr_ attribute + - Invalidate the cache of the cached property + + Raises AttributeError if the __attr_ attribute does not exist + """ + # Invalidate the cache of the cached property + try: # noqa: SIM105 suppress is much slower + delattr(o, name) + except AttributeError: + pass + # Delete the __attr_ attribute + delattr(o, private_attr_name) + + return _deleter + + def getter(name: str) -> Callable[[Any], Any]: + """Create a getter for an _attr_ property.""" + private_attr_name = f"__attr_{name}" + + def _getter(o: Any) -> Any: + """Get an _attr_ property from the backing __attr attribute.""" + return getattr(o, private_attr_name) + + return _getter + + def setter(name: str) -> Callable[[Any, Any], None]: + """Create a setter for an _attr_ property.""" + private_attr_name = f"__attr_{name}" + + def _setter(o: Any, val: Any) -> None: + """Set an _attr_ property to the backing __attr attribute. + + Also invalidates the corresponding cached_property by calling + delattr on it. + """ + if getattr(o, private_attr_name, _SENTINEL) == val: + return + setattr(o, private_attr_name, val) + try: # noqa: SIM105 suppress is much slower + delattr(o, name) + except AttributeError: + pass + + return _setter + + def make_property(name: str) -> property: + """Help create a property object.""" + return property(fget=getter(name), fset=setter(name), fdel=deleter(name)) + + def wrap_attr(cls: CachedProperties, property_name: str) -> None: + """Wrap a cached property's corresponding _attr in a property. + + If the class being created has an _attr class attribute, move it, and its + annotations, to the __attr attribute. + """ + attr_name = f"_attr_{property_name}" + private_attr_name = f"__attr_{property_name}" + # Check if an _attr_ class attribute exits and move it to __attr_. We check + # __dict__ here because we don't care about _attr_ class attributes in parents. + if attr_name in cls.__dict__: + setattr(cls, private_attr_name, getattr(cls, attr_name)) + annotations = cls.__annotations__ + if attr_name in annotations: + annotations[private_attr_name] = annotations.pop(attr_name) + # Create the _attr_ property + setattr(cls, attr_name, make_property(property_name)) + + cached_properties: set[str] = namespace["_CachedProperties__cached_properties"] + seen_props: set[str] = set() # Keep track of properties which have been handled + for property_name in cached_properties: + wrap_attr(cls, property_name) + seen_props.add(property_name) + + # Look for cached properties of parent classes where this class has + # corresponding _attr_ class attributes and re-wrap them. + for parent in cls.__mro__[:0:-1]: + if "_CachedProperties__cached_properties" not in parent.__dict__: + continue + cached_properties = getattr(parent, "_CachedProperties__cached_properties") + for property_name in cached_properties: + if property_name in seen_props: + continue + attr_name = f"_attr_{property_name}" + # Check if an _attr_ class attribute exits. We check __dict__ here because + # we don't care about _attr_ class attributes in parents. + if (attr_name) not in cls.__dict__: + continue + wrap_attr(cls, property_name) + seen_props.add(property_name) + + +class ABCCachedProperties(CachedProperties, ABCMeta): + """Add ABCMeta to CachedProperties.""" + + +CACHED_PROPERTIES_WITH_ATTR_ = { + "assumed_state", + "attribution", + "available", + "capability_attributes", + "device_class", + "device_info", + "entity_category", + "has_entity_name", + "entity_picture", + "entity_registry_enabled_default", + "entity_registry_visible_default", + "extra_state_attributes", + "force_update", + "icon", + "name", + "should_poll", + "state", + "supported_features", + "translation_key", + "translation_placeholders", + "unique_id", + "unit_of_measurement", +} + + +class Entity( + metaclass=ABCCachedProperties, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ +): """An abstract class for Home Assistant entities.""" # SAFE TO OVERWRITE @@ -261,6 +468,9 @@ class Entity(ABC): # If we reported if this entity was slow _slow_reported = False + # If we reported deprecated supported features constants + _deprecated_supported_features_reported = False + # If we reported this entity is updated while disabled _disabled_reported = False @@ -271,6 +481,9 @@ class Entity(ABC): # If we reported this entity was added without its platform set _no_platform_reported = False + # If we reported the name translation placeholders do not match the name + _name_translation_placeholders_reported = False + # Protect for multiple updates _update_staged = False @@ -311,6 +524,8 @@ class Entity(ABC): # and removes the need for constant None checks or asserts. _state_info: StateInfo = None # type: ignore[assignment] + __capabilities_updated_at: deque[float] + __capabilities_updated_at_reported: bool = False __remove_event: asyncio.Event | None = None # Entity Properties @@ -318,7 +533,6 @@ class Entity(ABC): _attr_attribution: str | None = None _attr_available: bool = True _attr_capability_attributes: Mapping[str, Any] | None = None - _attr_context_recent_time: timedelta = timedelta(seconds=5) _attr_device_class: str | None _attr_device_info: DeviceInfo | None = None _attr_entity_category: EntityCategory | None @@ -334,6 +548,7 @@ class Entity(ABC): _attr_state: StateType = STATE_UNKNOWN _attr_supported_features: int | None = None _attr_translation_key: str | None + _attr_translation_placeholders: Mapping[str, str] _attr_unique_id: str | None = None _attr_unit_of_measurement: str | None @@ -344,7 +559,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls._entity_component_unrecorded_attributes | cls._unrecorded_attributes ) - @property + @cached_property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -352,7 +567,7 @@ def should_poll(self) -> bool: """ return self._attr_should_poll - @property + @cached_property def unique_id(self) -> str | None: """Return a unique ID.""" return self._attr_unique_id @@ -375,7 +590,7 @@ def use_device_name(self) -> bool: return not self.name - @property + @cached_property def has_entity_name(self) -> bool: """Return if the name of the entity is describing only the entity itself.""" if hasattr(self, "_attr_has_entity_name"): @@ -425,6 +640,29 @@ def _name_translation_key(self) -> str | None: f".{self.translation_key}.name" ) + def _substitute_name_placeholders(self, name: str) -> str: + """Substitute placeholders in entity name.""" + try: + return name.format(**self.translation_placeholders) + except KeyError as err: + if not self._name_translation_placeholders_reported: + if get_release_channel() != "stable": + raise HomeAssistantError("Missing placeholder %s" % err) from err + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) has translation placeholders '%s' which do not " + "match the name '%s', please %s" + ), + self.entity_id, + type(self), + self.translation_placeholders, + name, + report_issue, + ) + self._name_translation_placeholders_reported = True + return name + def _name_internal( self, device_class_name: str | None, @@ -440,7 +678,7 @@ def _name_internal( ): if TYPE_CHECKING: assert isinstance(name, str) - return name + return self._substitute_name_placeholders(name) if hasattr(self, "entity_description"): description_name = self.entity_description.name if description_name is UNDEFINED and self._default_to_device_class_name(): @@ -456,10 +694,17 @@ def _name_internal( @property def suggested_object_id(self) -> str | None: """Return input for object id.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 - if self.__class__.name.fget is Entity.name.fget and self.platform: # type: ignore[attr-defined] + if ( + # Check our class has overridden the name property from Entity + # We need to use type.__getattribute__ to retrieve the underlying + # property or cached_property object instead of the property's + # value. + type.__getattribute__(self.__class__, "name") + is type.__getattribute__(Entity, "name") + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 + and self.platform + ): name = self._name_internal( self._object_id_device_class_name, self.platform.object_id_platform_translations, @@ -468,7 +713,7 @@ def suggested_object_id(self) -> str | None: name = self.name return None if name is UNDEFINED else name - @property + @cached_property def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" # The check for self.platform guards against integrations not using an @@ -480,12 +725,12 @@ def name(self) -> str | UndefinedType | None: self.platform.platform_translations, ) - @property + @cached_property def state(self) -> StateType: """Return the state of the entity.""" return self._attr_state - @property + @cached_property def capability_attributes(self) -> Mapping[str, Any] | None: """Return the capability attributes. @@ -508,7 +753,7 @@ def get_initial_entity_options(self) -> er.EntityOptionsType | None: """ return None - @property + @cached_property def state_attributes(self) -> dict[str, Any] | None: """Return the state attributes. @@ -517,16 +762,7 @@ def state_attributes(self) -> dict[str, Any] | None: """ return None - @property - def device_state_attributes(self) -> Mapping[str, Any] | None: - """Return entity specific state attributes. - - This method is deprecated, platform classes should implement - extra_state_attributes instead. - """ - return None - - @property + @cached_property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes. @@ -537,7 +773,7 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: return self._attr_extra_state_attributes return None - @property + @cached_property def device_info(self) -> DeviceInfo | None: """Return device specific attributes. @@ -545,7 +781,7 @@ def device_info(self) -> DeviceInfo | None: """ return self._attr_device_info - @property + @cached_property def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" if hasattr(self, "_attr_device_class"): @@ -554,7 +790,7 @@ def device_class(self) -> str | None: return self.entity_description.device_class return None - @property + @cached_property def unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" if hasattr(self, "_attr_unit_of_measurement"): @@ -563,7 +799,7 @@ def unit_of_measurement(self) -> str | None: return self.entity_description.unit_of_measurement return None - @property + @cached_property def icon(self) -> str | None: """Return the icon to use in the frontend, if any.""" if hasattr(self, "_attr_icon"): @@ -572,22 +808,22 @@ def icon(self) -> str | None: return self.entity_description.icon return None - @property + @cached_property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" return self._attr_entity_picture - @property + @cached_property def available(self) -> bool: """Return True if entity is available.""" return self._attr_available - @property + @cached_property def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" return self._attr_assumed_state - @property + @cached_property def force_update(self) -> bool: """Return True if state updates should be forced. @@ -600,17 +836,12 @@ def force_update(self) -> bool: return self.entity_description.force_update return False - @property + @cached_property def supported_features(self) -> int | None: """Flag supported features.""" return self._attr_supported_features - @property - def context_recent_time(self) -> timedelta: - """Time that a context is considered recent.""" - return self._attr_context_recent_time - - @property + @cached_property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added. @@ -622,7 +853,7 @@ def entity_registry_enabled_default(self) -> bool: return self.entity_description.entity_registry_enabled_default return True - @property + @cached_property def entity_registry_visible_default(self) -> bool: """Return if the entity should be visible when first added. @@ -634,12 +865,12 @@ def entity_registry_visible_default(self) -> bool: return self.entity_description.entity_registry_visible_default return True - @property + @cached_property def attribution(self) -> str | None: """Return the attribution.""" return self._attr_attribution - @property + @cached_property def entity_category(self) -> EntityCategory | None: """Return the category of the entity, if any.""" if hasattr(self, "_attr_entity_category"): @@ -648,7 +879,7 @@ def entity_category(self) -> EntityCategory | None: return self.entity_description.entity_category return None - @property + @cached_property def translation_key(self) -> str | None: """Return the translation key to translate the entity's states.""" if hasattr(self, "_attr_translation_key"): @@ -657,6 +888,16 @@ def translation_key(self) -> str | None: return self.entity_description.translation_key return None + @final + @cached_property + def translation_placeholders(self) -> Mapping[str, str]: + """Return the translation placeholders for translated entity's name.""" + if hasattr(self, "_attr_translation_placeholders"): + return self._attr_translation_placeholders + if hasattr(self, "entity_description"): + return self.entity_description.translation_placeholders or {} + return {} + # DO NOT OVERWRITE # These properties and methods are either managed by Home Assistant or they # are used to perform a very specific function. Overwriting these may @@ -770,17 +1011,34 @@ def _friendly_name_internal(self) -> str | None: return name device_name = device_entry.name_by_user or device_entry.name - if self.use_device_name: + if name is None and self.use_device_name: return device_name return f"{device_name} {name}" if device_name else name @callback - def _async_generate_attributes(self) -> tuple[str, dict[str, Any]]: + def _async_calculate_state(self) -> CalculatedState: """Calculate state string and attribute mapping.""" + return CalculatedState(*self.__async_calculate_state()) + + def __async_calculate_state( + self, + ) -> tuple[str, dict[str, Any], Mapping[str, Any] | None, Mapping[str, Any]]: + """Calculate state string and attribute mapping. + + Returns a tuple (state, attr, capability_attr, shadowed_attr). + state - the stringified state + attr - the attribute dictionary + capability_attr - a mapping with capability attributes + shadowed_attr - a mapping with attributes which may be overridden + + This method is called when writing the state to avoid the overhead of creating + a dataclass object. + """ entry = self.registry_entry - attr = self.capability_attributes - attr = dict(attr) if attr else {} + capability_attr = self.capability_attributes + attr = dict(capability_attr) if capability_attr else {} + shadowed_attr = {} available = self.available # only call self.available once per update cycle state = self._stringify_state(available) @@ -797,26 +1055,30 @@ def _async_generate_attributes(self) -> tuple[str, dict[str, Any]]: if (attribution := self.attribution) is not None: attr[ATTR_ATTRIBUTION] = attribution + shadowed_attr[ATTR_DEVICE_CLASS] = self.device_class if ( - device_class := (entry and entry.device_class) or self.device_class + device_class := (entry and entry.device_class) + or shadowed_attr[ATTR_DEVICE_CLASS] ) is not None: attr[ATTR_DEVICE_CLASS] = str(device_class) if (entity_picture := self.entity_picture) is not None: attr[ATTR_ENTITY_PICTURE] = entity_picture - if (icon := (entry and entry.icon) or self.icon) is not None: + shadowed_attr[ATTR_ICON] = self.icon + if (icon := (entry and entry.icon) or shadowed_attr[ATTR_ICON]) is not None: attr[ATTR_ICON] = icon + shadowed_attr[ATTR_FRIENDLY_NAME] = self._friendly_name_internal() if ( - name := (entry and entry.name) or self._friendly_name_internal() + name := (entry and entry.name) or shadowed_attr[ATTR_FRIENDLY_NAME] ) is not None: attr[ATTR_FRIENDLY_NAME] = name if (supported_features := self.supported_features) is not None: attr[ATTR_SUPPORTED_FEATURES] = supported_features - return (state, attr) + return (state, attr, capability_attr, shadowed_attr) @callback def _async_write_ha_state(self) -> None: @@ -842,9 +1104,45 @@ def _async_write_ha_state(self) -> None: return start = timer() - state, attr = self._async_generate_attributes() + state, attr, capabilities, shadowed_attr = self.__async_calculate_state() end = timer() + if entry: + # Make sure capabilities in the entity registry are up to date. Capabilities + # include capability attributes, device class and supported features + original_device_class: str | None = shadowed_attr[ATTR_DEVICE_CLASS] + supported_features: int = attr.get(ATTR_SUPPORTED_FEATURES) or 0 + if ( + capabilities != entry.capabilities + or original_device_class != entry.original_device_class + or supported_features != entry.supported_features + ): + if not self.__capabilities_updated_at_reported: + time_now = hass.loop.time() + capabilities_updated_at = self.__capabilities_updated_at + capabilities_updated_at.append(time_now) + while time_now - capabilities_updated_at[0] > 3600: + capabilities_updated_at.popleft() + if len(capabilities_updated_at) > CAPABILITIES_UPDATE_LIMIT: + self.__capabilities_updated_at_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) is updating its capabilities too often," + " please %s" + ), + entity_id, + type(self), + report_issue, + ) + entity_registry = er.async_get(self.hass) + self.registry_entry = entity_registry.async_update_entity( + self.entity_id, + capabilities=capabilities, + original_device_class=original_device_class, + supported_features=supported_features, + ) + if end - start > 0.4 and not self._slow_reported: self._slow_reported = True report_issue = self._suggest_report_issue() @@ -863,7 +1161,7 @@ def _async_write_ha_state(self) -> None: if ( self._context_set is not None and hass.loop.time() - self._context_set - > self.context_recent_time.total_seconds() + > CONTEXT_RECENT_TIME.total_seconds() ): self._context = None self._context_set = None @@ -1118,6 +1416,8 @@ async def async_internal_added_to_hass(self) -> None: ) self._async_subscribe_device_updates() + self.__capabilities_updated_at = deque(maxlen=CAPABILITIES_UPDATE_LIMIT + 1) + async def async_internal_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -1221,7 +1521,12 @@ def _async_subscribe_device_updates(self) -> None: self.async_on_remove(self._async_unsubscribe_device_updates) def __repr__(self) -> str: - """Return the representation.""" + """Return the representation. + + If the entity is not added to a platform it's not safe to call _stringify_state. + """ + if self._platform_state != EntityPlatformState.ADDED: + return f"" return f"" async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: @@ -1244,13 +1549,42 @@ def _suggest_report_issue(self) -> str: self.hass, integration_domain=platform_name, module=type(self).__module__ ) + @callback + def _report_deprecated_supported_features_values( + self, replacement: IntFlag + ) -> None: + """Report deprecated supported features values.""" + if self._deprecated_supported_features_reported is True: + return + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s, please %s" + ), + self.entity_id, + type(self), + repr(replacement), + report_issue, + ) + -@dataclass(slots=True) -class ToggleEntityDescription(EntityDescription): +class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" -class ToggleEntity(Entity): +TOGGLE_ENTITY_CACHED_PROPERTIES_WITH_ATTR_ = {"is_on"} + + +class ToggleEntity( + Entity, cached_properties=TOGGLE_ENTITY_CACHED_PROPERTIES_WITH_ATTR_ +): """An abstract class for entities that can be turned on and off.""" entity_description: ToggleEntityDescription @@ -1265,7 +1599,7 @@ def state(self) -> Literal["on", "off"] | None: return None return STATE_ON if is_on else STATE_OFF - @property + @cached_property def is_on(self) -> bool | None: """Return True if entity is on.""" return self._attr_is_on diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index ddd467592590fb..b3eb8722997f60 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -32,7 +32,7 @@ from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform -from . import config_per_platform, config_validation as cv, discovery, entity, service +from . import config_validation as cv, discovery, entity, service from .entity_platform import EntityPlatform from .typing import ConfigType, DiscoveryInfoType @@ -89,12 +89,13 @@ def __init__( self.config: ConfigType | None = None + domain_platform = self._async_init_entity_platform(domain, None) self._platforms: dict[ str | tuple[str, timedelta | None, str | None], EntityPlatform - ] = {domain: self._async_init_entity_platform(domain, None)} - self.async_add_entities = self._platforms[domain].async_add_entities - self.add_entities = self._platforms[domain].add_entities - + ] = {domain: domain_platform} + self.async_add_entities = domain_platform.async_add_entities + self.add_entities = domain_platform.add_entities + self._entities: dict[str, entity.Entity] = domain_platform.domain_entities hass.data.setdefault(DATA_INSTANCES, {})[domain] = self @property @@ -105,18 +106,11 @@ def entities(self) -> Iterable[_EntityT]: callers that iterate over this asynchronously should make a copy using list() before iterating. """ - return chain.from_iterable( - platform.entities.values() # type: ignore[misc] - for platform in self._platforms.values() - ) + return self._entities.values() # type: ignore[return-value] def get_entity(self, entity_id: str) -> _EntityT | None: """Get an entity.""" - for platform in self._platforms.values(): - entity_obj = platform.entities.get(entity_id) - if entity_obj is not None: - return entity_obj # type: ignore[return-value] - return None + return self._entities.get(entity_id) # type: ignore[return-value] def register_shutdown(self) -> None: """Register shutdown on Home Assistant STOP event. @@ -148,7 +142,7 @@ async def async_setup(self, config: ConfigType) -> None: self.config = config # Look in config for Domain, Domain 2, Domain 3 etc and load them - for p_type, p_config in config_per_platform(config, self.domain): + for p_type, p_config in conf_util.config_per_platform(config, self.domain): if p_type is not None: self.hass.async_create_task( self.async_setup_platform(p_type, p_config), @@ -237,7 +231,7 @@ async def handle_service( """Handle the service.""" result = await service.entity_service_call( - self.hass, self._platforms.values(), func, call, required_features + self.hass, self._entities, func, call, required_features ) if result: @@ -270,7 +264,7 @@ async def handle_service( ) -> EntityServiceResponse | None: """Handle the service.""" return await service.entity_service_call( - self.hass, self._platforms.values(), func, call, required_features + self.hass, self._entities, func, call, required_features ) self.hass.services.async_register( @@ -355,7 +349,7 @@ async def async_prepare_reload( 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 388c00bd177a5d..1bf7d95135ba87 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -31,7 +31,6 @@ from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.generated import languages from homeassistant.setup import async_start_setup -from homeassistant.util.async_ import run_callback_threadsafe from . import ( config_validation as cv, @@ -56,6 +55,7 @@ PLATFORM_NOT_READY_RETRIES = 10 DATA_ENTITY_PLATFORM = "entity_platform" +DATA_DOMAIN_ENTITIES = "domain_entities" PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds _LOGGER = getLogger(__name__) @@ -148,6 +148,10 @@ def __init__( self.platform_name, [] ).append(self) + self.domain_entities: dict[str, Entity] = hass.data.setdefault( + DATA_DOMAIN_ENTITIES, {} + ).setdefault(domain, {}) + def __repr__(self) -> str: """Represent an EntityPlatform.""" return ( @@ -304,7 +308,7 @@ async def _async_setup_platform( current_platform.set(self) logger = self.logger hass = self.hass - full_name = f"{self.domain}.{self.platform_name}" + full_name = f"{self.platform_name}.{self.domain}" object_id_language = ( hass.config.language if hass.config.language in languages.NATIVE_ENTITY_IDS @@ -429,12 +433,11 @@ def _schedule_add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Schedule adding entities for a single platform, synchronously.""" - run_callback_threadsafe( - self.hass.loop, + self.hass.loop.call_soon_threadsafe( self._async_schedule_add_entities, list(new_entities), update_before_add, - ).result() + ) @callback def _async_schedule_add_entities( @@ -736,6 +739,7 @@ async def _async_add_entity( # noqa: C901 entity_id = entity.entity_id self.entities[entity_id] = entity + self.domain_entities[entity_id] = entity if not restored: # Reserve the state in the state machine @@ -748,6 +752,7 @@ async def _async_add_entity( # noqa: C901 def remove_entity_cb() -> None: """Remove entity from entities dict.""" self.entities.pop(entity_id) + self.domain_entities.pop(entity_id) entity.async_on_remove(remove_entity_cb) @@ -813,7 +818,7 @@ async def async_extract_from_service( 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, @@ -832,11 +837,7 @@ async def handle_service(call: ServiceCall) -> EntityServiceResponse | None: """Handle the service.""" return await service.entity_service_call( self.hass, - [ - plf - for plf in self.hass.data[DATA_ENTITY_PLATFORM][self.platform_name] - if plf.domain == self.domain - ], + self.domain_entities, func, call, required_features, diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 1a449ec15f0f36..dd61357f53eb00 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -93,7 +93,7 @@ def convert_filter(config: dict[str, list[str]]) -> EntityFilter: def convert_include_exclude_filter( - config: dict[str, dict[str, list[str]]] + config: dict[str, dict[str, list[str]]], ) -> EntityFilter: """Convert the include exclude filter schema into a filter.""" include = config[CONF_INCLUDE] diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 648e0e5bd092c2..02add8ff0121a3 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -136,7 +136,7 @@ class EventStateChangedData(TypedDict): def threaded_listener_factory( - async_factory: Callable[Concatenate[HomeAssistant, _P], Any] + async_factory: Callable[Concatenate[HomeAssistant, _P], Any], ) -> Callable[Concatenate[HomeAssistant, _P], CALLBACK_TYPE]: """Convert an async event helper to a threaded one.""" @@ -251,7 +251,9 @@ def state_change_listener(event: EventType[EventStateChangedData]) -> None: 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] ) @@ -761,7 +763,8 @@ def _setup_domains_listener(self, domains: set[str]) -> None: @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] ) @@ -1335,7 +1338,8 @@ def state_for_cancel_listener(event: EventType[EventStateChangedData]) -> None: 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( diff --git a/homeassistant/helpers/group.py b/homeassistant/helpers/group.py new file mode 100644 index 00000000000000..437df226118eb8 --- /dev/null +++ b/homeassistant/helpers/group.py @@ -0,0 +1,58 @@ +"""Helper for groups.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE +from homeassistant.core import HomeAssistant + +ENTITY_PREFIX = "group." + + +def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[str]: + """Return entity_ids with group entity ids replaced by their members. + + Async friendly. + """ + found_ids: list[str] = [] + for entity_id in entity_ids: + if not isinstance(entity_id, str) or entity_id in ( + ENTITY_MATCH_NONE, + ENTITY_MATCH_ALL, + ): + continue + + entity_id = entity_id.lower() + # If entity_id points at a group, expand it + if entity_id.startswith(ENTITY_PREFIX): + child_entities = get_entity_ids(hass, entity_id) + if entity_id in child_entities: + child_entities = list(child_entities) + child_entities.remove(entity_id) + found_ids.extend( + ent_id + for ent_id in expand_entity_ids(hass, child_entities) + if ent_id not in found_ids + ) + elif entity_id not in found_ids: + found_ids.append(entity_id) + + return found_ids + + +def get_entity_ids( + hass: HomeAssistant, entity_id: str, domain_filter: str | None = None +) -> list[str]: + """Get members of this group. + + Async friendly. + """ + group = hass.states.get(entity_id) + if not group or ATTR_ENTITY_ID not in group.attributes: + return [] + entity_ids: list[str] = group.attributes[ATTR_ENTITY_ID] + if not domain_filter: + return entity_ids + domain_filter = f"{domain_filter.lower()}." + return [ent_id for ent_id in entity_ids if ent_id.startswith(domain_filter)] diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 12accf2725a961..58ca191feb05b4 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/ratelimit.py b/homeassistant/helpers/ratelimit.py index 1da79eb5f7d242..b2a93e7302f7e9 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -5,11 +5,13 @@ from collections.abc import Callable, Hashable from datetime import datetime, timedelta import logging -from typing import Any +from typing import TypeVarTuple from homeassistant.core import HomeAssistant, callback import homeassistant.util.dt as dt_util +_Ts = TypeVarTuple("_Ts") + _LOGGER = logging.getLogger(__name__) @@ -59,8 +61,8 @@ def async_schedule_action( key: Hashable, rate_limit: timedelta | None, now: datetime, - action: Callable, - *args: Any, + action: Callable[[*_Ts], None], + *args: *_Ts, ) -> datetime | None: """Check rate limits and schedule an action if we hit the limit. diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 75529476dd20ef..983b4e2da52b08 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -4,7 +4,7 @@ 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 @@ -13,7 +13,6 @@ from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component -from . import config_per_platform from .entity import Entity from .entity_component import EntityComponent from .entity_platform import EntityPlatform, async_get_platforms @@ -26,7 +25,7 @@ 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 +43,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 +52,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 conf_util.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 +80,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 +135,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 4dd71a584ec58f..0878114552f1aa 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -178,8 +178,8 @@ def async_get_stored_states(self) -> list[StoredState]: now = dt_util.utcnow() all_states = self.hass.states.async_all() # Entities currently backed by an entity object - current_entity_ids = { - state.entity_id + current_states_by_entity_id = { + state.entity_id: state for state in all_states if not state.attributes.get(ATTR_RESTORED) } @@ -187,12 +187,12 @@ def async_get_stored_states(self) -> list[StoredState]: # Start with the currently registered states stored_states = [ StoredState( - state, self.entities[state.entity_id].extra_restore_state_data, now + current_states_by_entity_id[entity_id], + entity.extra_restore_state_data, + now, ) - for state in all_states - if state.entity_id in self.entities and - # Ignore all states that are entity registry placeholders - not state.attributes.get(ATTR_RESTORED) + for entity_id, entity in self.entities.items() + if entity_id in current_states_by_entity_id ] expiration_time = now - STATE_EXPIRATION @@ -200,7 +200,7 @@ def async_get_stored_states(self) -> list[StoredState]: # Don't save old states that have entities in the current run # They are either registered and already part of stored_states, # or no longer care about restoring. - if entity_id in current_entity_ids: + if entity_id in current_states_by_entity_id: continue # Don't save old states that have expired diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index dcf7f07bf6b3e2..2bbad0ed63afd0 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -331,7 +331,12 @@ def async_supports_options_flow( return cls.options_flow is not None @staticmethod - def _async_step(step_id: str) -> Callable: + def _async_step( + step_id: str, + ) -> Callable[ + [SchemaConfigFlowHandler, dict[str, Any] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler.""" async def _async_step( @@ -421,7 +426,12 @@ def __init__( setattr(self, "async_setup_preview", async_setup_preview) @staticmethod - def _async_step(step_id: str) -> Callable: + def _async_step( + step_id: str, + ) -> Callable[ + [SchemaConfigFlowHandler, dict[str, Any] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler.""" async def _async_step( diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a1d045eb542d39..07f10e13dbf62c 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Mapping, Sequence +from collections.abc import AsyncGenerator, Callable, Mapping, Sequence from contextlib import asynccontextmanager, suppress from contextvars import ContextVar from copy import copy @@ -157,7 +157,12 @@ def action_trace_append(variables, path): @asynccontextmanager -async def trace_action(hass, script_run, stop, variables): +async def trace_action( + hass: HomeAssistant, + script_run: _ScriptRun, + stop: asyncio.Event, + variables: dict[str, Any], +) -> AsyncGenerator[TraceElement, None]: """Trace action execution.""" path = trace_path_get() trace_element = action_trace_append(variables, path) @@ -362,6 +367,8 @@ def __init__(self, message: str, response: Any) -> None: class _ScriptRun: """Manage Script sequence run.""" + _action: dict[str, Any] + def __init__( self, hass: HomeAssistant, @@ -376,7 +383,6 @@ def __init__( self._context = context self._log_exceptions = log_exceptions self._step = -1 - self._action: dict[str, Any] | None = None self._stop = asyncio.Event() self._stopped = asyncio.Event() @@ -446,11 +452,13 @@ async def async_run(self) -> ScriptRunResult | None: return ScriptRunResult(response, self._variables) - async def _async_step(self, log_exceptions): + async def _async_step(self, log_exceptions: bool) -> None: continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) with trace_path(str(self._step)): - async with trace_action(self._hass, self, self._stop, self._variables): + async with trace_action( + self._hass, self, self._stop, self._variables + ) as trace_element: if self._stop.is_set(): return @@ -466,6 +474,7 @@ async def _async_step(self, log_exceptions): try: handler = f"_async_{action}_step" await getattr(self, handler)() + trace_element.update_variables(self._variables) except Exception as ex: # pylint: disable=broad-except self._handle_exception( ex, continue_on_error, self._log_exceptions or log_exceptions diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index ac5166911ffa2e..9c4266583e8d4e 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -102,6 +102,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: from homeassistant.components.todo import TodoListEntityFeature from homeassistant.components.update import UpdateEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature + from homeassistant.components.valve import ValveEntityFeature from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.components.weather import WeatherEntityFeature @@ -122,6 +123,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: "TodoListEntityFeature": TodoListEntityFeature, "UpdateEntityFeature": UpdateEntityFeature, "VacuumEntityFeature": VacuumEntityFeature, + "ValveEntityFeature": ValveEntityFeature, "WaterHeaterEntityFeature": WaterHeaterEntityFeature, "WeatherEntityFeature": WeatherEntityFeature, } @@ -425,10 +427,20 @@ def __call__(self, data: Any) -> list[int]: 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 +449,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,11 +465,20 @@ def __init__(self, config: ColorTempSelectorConfig | None = None) -> None: 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 @@ -927,9 +953,6 @@ def validate_slider(data: Any) -> Any: if "min" not in data or "max" not in data: raise vol.Invalid("min and max are required in slider mode") - if "step" in data and data["step"] == "any": - raise vol.Invalid("step 'any' is not allowed in slider mode") - return data @@ -1182,6 +1205,7 @@ class TextSelectorConfig(TypedDict, total=False): suffix: str type: TextSelectorType autocomplete: str + multiple: bool class TextSelectorType(StrEnum): @@ -1219,6 +1243,7 @@ class TextSelector(Selector[TextSelectorConfig]): vol.Coerce(TextSelectorType), lambda val: val.value ), vol.Optional("autocomplete"): str, + vol.Optional("multiple", default=False): bool, } ) @@ -1226,10 +1251,14 @@ def __init__(self, config: TextSelectorConfig | None = None) -> None: """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): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 32f51a924f7be8..4813a54ac8bb42 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -42,7 +42,7 @@ UnknownUser, ) from homeassistant.loader import Integration, async_get_integrations, bind_hass -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE from . import ( @@ -53,12 +53,12 @@ template, translation, ) +from .group import expand_entity_ids from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType if TYPE_CHECKING: from .entity import Entity - from .entity_platform import EntityPlatform _EntityT = TypeVar("_EntityT", bound=Entity) @@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]: media_player, remote, siren, + todo, update, vacuum, water_heater, @@ -106,6 +107,7 @@ def _base_components() -> dict[str, ModuleType]: "media_player": media_player, "remote": remote, "siren": siren, + "todo": todo, "update": update, "vacuum": vacuum, "water_heater": water_heater, @@ -458,9 +460,9 @@ def async_extract_referenced_entity_ids( if not selector.has_any_selector: return selected - entity_ids = selector.entity_ids + entity_ids: set[str] | list[str] = selector.entity_ids if expand_group: - entity_ids = hass.components.group.expand_entity_ids(entity_ids) + entity_ids = expand_entity_ids(hass, entity_ids) selected.referenced.update(entity_ids) @@ -542,7 +544,9 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T try: return cast( JSON_TYPE, - _SERVICES_SCHEMA(load_yaml(str(integration.file_path / "services.yaml"))), + _SERVICES_SCHEMA( + load_yaml_dict(str(integration.file_path / "services.yaml")) + ), ) except FileNotFoundError: _LOGGER.warning( @@ -737,7 +741,7 @@ def async_set_service_schema( def _get_permissible_entity_candidates( call: ServiceCall, - platforms: Iterable[EntityPlatform], + entities: dict[str, Entity], entity_perms: None | (Callable[[str, str], bool]), target_all_entities: bool, all_referenced: set[str] | None, @@ -750,9 +754,8 @@ def _get_permissible_entity_candidates( # is allowed to control. return [ entity - for platform in platforms - for entity in platform.entities.values() - if entity_perms(entity.entity_id, POLICY_CONTROL) + for entity_id, entity in entities.items() + if entity_perms(entity_id, POLICY_CONTROL) ] assert all_referenced is not None @@ -767,29 +770,26 @@ def _get_permissible_entity_candidates( ) elif target_all_entities: - return [ - entity for platform in platforms for entity in platform.entities.values() - ] + return list(entities.values()) # We have already validated they have permissions to control all_referenced # entities so we do not need to check again. - assert all_referenced is not None - if single_entity := len(all_referenced) == 1 and list(all_referenced)[0]: - for platform in platforms: - if (entity := platform.entities.get(single_entity)) is not None: - return [entity] + if TYPE_CHECKING: + assert all_referenced is not None + if ( + len(all_referenced) == 1 + and (single_entity := list(all_referenced)[0]) + and (entity := entities.get(single_entity)) is not None + ): + return [entity] - return [ - platform.entities[entity_id] - for platform in platforms - for entity_id in all_referenced.intersection(platform.entities) - ] + return [entities[entity_id] for entity_id in all_referenced.intersection(entities)] @bind_hass async def entity_service_call( hass: HomeAssistant, - platforms: Iterable[EntityPlatform], + registered_entities: dict[str, Entity], func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], call: ServiceCall, required_features: Iterable[int] | None = None, @@ -828,7 +828,7 @@ async def entity_service_call( # A list with entities to call the service on. entity_candidates = _get_permissible_entity_candidates( call, - platforms, + registered_entities, entity_perms, target_all_entities, all_referenced, @@ -996,7 +996,7 @@ def verify_domain_control( """Ensure permission to access any entity under domain in service call.""" def decorator( - service_handler: Callable[[ServiceCall], Any] + service_handler: Callable[[ServiceCall], Any], ) -> Callable[[ServiceCall], Any]: """Decorate.""" if not asyncio.iscoroutinefunction(service_handler): diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index 589d792f1f8109..9bda3ca4eb2ec9 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -147,6 +147,15 @@ def percentage_change(old_state: int | float, new_state: int | float) -> float: return _check_numeric_change(old_state, new_state, change, percentage_change) +def check_valid_float(value: str | int | float) -> bool: + """Check if given value is a valid float.""" + try: + float(value) + except ValueError: + return False + return True + + class SignificantlyChangedChecker: """Class to keep track of entities to see if they have significantly changed. diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 721ac8bd5bee6e..ad8c4ba771a6de 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ import asyncio import base64 import collections.abc -from collections.abc import Callable, Collection, Generator, Iterable, MutableMapping +from collections.abc import Callable, Collection, Generator, Iterable from contextlib import AbstractContextManager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -40,7 +40,7 @@ from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU import orjson import voluptuous as vol @@ -147,10 +147,8 @@ MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 -CACHED_TEMPLATE_LRU: MutableMapping[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) -CACHED_TEMPLATE_NO_COLLECT_LRU: MutableMapping[State, TemplateState] = LRU( - CACHED_TEMPLATE_STATES -) +CACHED_TEMPLATE_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) +CACHED_TEMPLATE_NO_COLLECT_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) ENTITY_COUNT_GROWTH_FACTOR = 1.2 ORJSON_PASSTHROUGH_OPTIONS = ( @@ -187,9 +185,9 @@ def _async_adjust_lru_sizes(_: Any) -> None: ) for lru in (CACHED_TEMPLATE_LRU, CACHED_TEMPLATE_NO_COLLECT_LRU): # There is no typing for LRU - current_size = lru.get_size() # type: ignore[attr-defined] + current_size = lru.get_size() if new_size > current_size: - lru.set_size(new_size) # type: ignore[attr-defined] + lru.set_size(new_size) from .event import ( # pylint: disable=import-outside-toplevel async_track_time_interval, @@ -653,7 +651,7 @@ def _render_template() -> None: except Exception: # pylint: disable=broad-except self._exc_info = sys.exc_info() finally: - run_callback_threadsafe(self.hass.loop, finish_event.set) + self.hass.loop.call_soon_threadsafe(finish_event.set) try: template_render_thread = ThreadWithException(target=_render_template) @@ -1910,6 +1908,66 @@ def average(*args: Any, default: Any = _SENTINEL) -> Any: return default +def median(*args: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to calculate the median. + + Calculates median of an iterable of two or more arguments. + + The parameters may be passed as an iterable or as separate arguments. + """ + if len(args) == 0: + raise TypeError("median expected at least 1 argument, got 0") + + # If first argument is a list or tuple and more than 1 argument provided but not a named + # default, then use 2nd argument as default. + if isinstance(args[0], Iterable): + median_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + median_list = args + + try: + return statistics.median(median_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("median", args) + return default + + +def statistical_mode(*args: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to calculate the statistical mode. + + Calculates mode of an iterable of two or more arguments. + + The parameters may be passed as an iterable or as separate arguments. + """ + if not args: + raise TypeError("statistical_mode expected at least 1 argument, got 0") + + # If first argument is a list or tuple and more than 1 argument provided but not a named + # default, then use 2nd argument as default. + if len(args) == 1 and isinstance(args[0], Iterable): + mode_list = args[0] + elif isinstance(args[0], list | tuple): + mode_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + mode_list = args + + try: + return statistics.mode(mode_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("statistical_mode", args) + return default + + def forgiving_float(value, default=_SENTINEL): """Try to convert value to a float.""" try: @@ -2125,6 +2183,10 @@ def to_json( option = ( ORJSON_PASSTHROUGH_OPTIONS + # OPT_NON_STR_KEYS is added as a workaround to + # ensure subclasses of str are allowed as dict keys + # See: https://github.com/ijl/orjson/issues/445 + | orjson.OPT_NON_STR_KEYS | (orjson.OPT_INDENT_2 if pretty_print else 0) | (orjson.OPT_SORT_KEYS if sort_keys else 0) ) @@ -2388,6 +2450,8 @@ def __init__( self.filters["from_json"] = from_json self.filters["is_defined"] = fail_when_undefined self.filters["average"] = average + self.filters["median"] = median + self.filters["statistical_mode"] = statistical_mode self.filters["random"] = random_every_time self.filters["base64_encode"] = base64_encode self.filters["base64_decode"] = base64_decode @@ -2410,6 +2474,8 @@ def __init__( self.filters["bool"] = forgiving_boolean self.filters["version"] = version self.filters["contains"] = contains + self.filters["median"] = median + self.filters["statistical_mode"] = statistical_mode self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -2431,6 +2497,8 @@ def __init__( self.globals["strptime"] = strptime self.globals["urlencode"] = urlencode self.globals["average"] = average + self.globals["median"] = median + self.globals["statistical_mode"] = statistical_mode self.globals["max"] = min_max_from_filter(self.filters["max"], "max") self.globals["min"] = min_max_from_filter(self.filters["min"], "min") self.globals["is_number"] = is_number @@ -2443,6 +2511,8 @@ def __init__( self.globals["iif"] = iif self.globals["bool"] = forgiving_boolean self.globals["version"] = version + self.globals["median"] = median + self.globals["statistical_mode"] = statistical_mode self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index fd7a3081f7a345..53e66e1c651cac 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -2,17 +2,20 @@ from __future__ import annotations from collections import deque -from collections.abc import Callable, Generator +from collections.abc import Callable, Coroutine, Generator from contextlib import contextmanager from contextvars import ContextVar from functools import wraps -from typing import Any, cast +from typing import Any, TypeVar, TypeVarTuple from homeassistant.core import ServiceResponse import homeassistant.util.dt as dt_util from .typing import TemplateVarsType +_T = TypeVar("_T") +_Ts = TypeVarTuple("_Ts") + class TraceElement: """Container for trace data.""" @@ -21,6 +24,7 @@ class TraceElement: "_child_key", "_child_run_id", "_error", + "_last_variables", "path", "_result", "reuse_by_child", @@ -38,16 +42,8 @@ def __init__(self, variables: TemplateVarsType, path: str) -> None: self.reuse_by_child = False self._timestamp = dt_util.utcnow() - if variables is None: - variables = {} - last_variables = variables_cv.get() or {} - variables_cv.set(dict(variables)) - changed_variables = { - key: value - for key, value in variables.items() - if key not in last_variables or last_variables[key] != value - } - self._variables = changed_variables + self._last_variables = variables_cv.get() or {} + self.update_variables(variables) def __repr__(self) -> str: """Container for trace data.""" @@ -71,6 +67,19 @@ def update_result(self, **kwargs: Any) -> None: old_result = self._result or {} self._result = {**old_result, **kwargs} + def update_variables(self, variables: TemplateVarsType) -> None: + """Update variables.""" + if variables is None: + variables = {} + last_variables = self._last_variables + variables_cv.set(dict(variables)) + changed_variables = { + key: value + for key, value in variables.items() + if key not in last_variables or last_variables[key] != value + } + self._variables = changed_variables + def as_dict(self) -> dict[str, Any]: """Return dictionary version of this TraceElement.""" result: dict[str, Any] = {"path": self.path, "timestamp": self._timestamp} @@ -125,21 +134,23 @@ def trace_id_get() -> tuple[str, str] | None: return trace_id_cv.get() -def trace_stack_push(trace_stack_var: ContextVar, node: Any) -> None: +def trace_stack_push(trace_stack_var: ContextVar[list[_T] | None], node: _T) -> None: """Push an element to the top of a trace stack.""" + trace_stack: list[_T] | None if (trace_stack := trace_stack_var.get()) is None: trace_stack = [] trace_stack_var.set(trace_stack) trace_stack.append(node) -def trace_stack_pop(trace_stack_var: ContextVar) -> None: +def trace_stack_pop(trace_stack_var: ContextVar[list[Any] | None]) -> None: """Remove the top element from a trace stack.""" trace_stack = trace_stack_var.get() - trace_stack.pop() + if trace_stack is not None: + trace_stack.pop() -def trace_stack_top(trace_stack_var: ContextVar) -> Any | None: +def trace_stack_top(trace_stack_var: ContextVar[list[_T] | None]) -> _T | None: """Return the element at the top of a trace stack.""" trace_stack = trace_stack_var.get() return trace_stack[-1] if trace_stack else None @@ -198,21 +209,20 @@ def trace_clear() -> None: def trace_set_child_id(child_key: str, child_run_id: str) -> None: """Set child trace_id of TraceElement at the top of the stack.""" - node = cast(TraceElement, trace_stack_top(trace_stack_cv)) - if node: + if node := trace_stack_top(trace_stack_cv): node.set_child_id(child_key, child_run_id) def trace_set_result(**kwargs: Any) -> None: """Set the result of TraceElement at the top of the stack.""" - node = cast(TraceElement, trace_stack_top(trace_stack_cv)) - node.set_result(**kwargs) + if node := trace_stack_top(trace_stack_cv): + node.set_result(**kwargs) def trace_update_result(**kwargs: Any) -> None: """Update the result of TraceElement at the top of the stack.""" - node = cast(TraceElement, trace_stack_top(trace_stack_cv)) - node.update_result(**kwargs) + if node := trace_stack_top(trace_stack_cv): + node.update_result(**kwargs) class StopReason: @@ -238,7 +248,7 @@ def script_execution_get() -> str | None: @contextmanager -def trace_path(suffix: str | list[str]) -> Generator: +def trace_path(suffix: str | list[str]) -> Generator[None, None, None]: """Go deeper in the config tree. Can not be used as a decorator on couroutine functions. @@ -250,17 +260,24 @@ def trace_path(suffix: str | list[str]) -> Generator: trace_path_pop(count) -def async_trace_path(suffix: str | list[str]) -> Callable: +def async_trace_path( + suffix: str | list[str], +) -> Callable[ + [Callable[[*_Ts], Coroutine[Any, Any, None]]], + Callable[[*_Ts], Coroutine[Any, Any, None]], +]: """Go deeper in the config tree. To be used as a decorator on coroutine functions. """ - def _trace_path_decorator(func: Callable) -> Callable: + def _trace_path_decorator( + func: Callable[[*_Ts], Coroutine[Any, Any, None]], + ) -> Callable[[*_Ts], Coroutine[Any, Any, None]]: """Decorate a coroutine function.""" @wraps(func) - async def async_wrapper(*args: Any) -> None: + async def async_wrapper(*args: *_Ts) -> None: """Catch and log exception.""" with trace_path(suffix): await func(*args) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 41ad591d878994..4e13707257b323 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Iterable, Mapping import logging +import string from typing import Any from homeassistant.core import HomeAssistant, callback @@ -48,7 +49,7 @@ def component_translation_path( If component is just a single file, will return None. """ parts = component.split(".") - domain = parts[-1] + domain = parts[0] is_platform = len(parts) == 2 # If it's a component that is just one file, we don't support translations @@ -57,7 +58,7 @@ def component_translation_path( return None if is_platform: - filename = f"{parts[0]}.{language}.json" + filename = f"{parts[1]}.{language}.json" else: filename = f"{language}.json" @@ -67,7 +68,7 @@ def component_translation_path( def load_translations_files( - translation_files: dict[str, str] + translation_files: dict[str, str], ) -> dict[str, dict[str, Any]]: """Load and parse translation.json files.""" loaded = {} @@ -96,7 +97,7 @@ def _merge_resources( # Build response resources: dict[str, dict[str, Any]] = {} for component in components: - domain = component.partition(".")[0] + domain = component.rpartition(".")[-1] domain_resources = resources.setdefault(domain, {}) @@ -154,7 +155,7 @@ async def _async_get_component_strings( # Determine paths of missing components/platforms files_to_load = {} for loaded in components: - domain = loaded.rpartition(".")[-1] + domain = loaded.partition(".")[0] integration = integrations[domain] path = component_translation_path(loaded, language, integration) @@ -225,7 +226,7 @@ async def _async_load(self, language: str, components: set[str]) -> None: languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language] integrations: dict[str, Integration] = {} - domains = list({loaded.rpartition(".")[-1] for loaded in components}) + domains = list({loaded.partition(".")[0] for loaded in components}) ints_or_excs = await async_get_integrations(self.hass, domains) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): @@ -242,6 +243,42 @@ async def _async_load(self, language: str, components: set[str]) -> None: self.loaded[language].update(components) + def _validate_placeholders( + self, + language: str, + updated_resources: dict[str, Any], + cached_resources: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Validate if updated resources have same placeholders as cached resources.""" + if cached_resources is None: + return updated_resources + + mismatches: set[str] = set() + + for key, value in updated_resources.items(): + if key not in cached_resources: + continue + tuples = list(string.Formatter().parse(value)) + updated_placeholders = {tup[1] for tup in tuples if tup[1] is not None} + + tuples = list(string.Formatter().parse(cached_resources[key])) + cached_placeholders = {tup[1] for tup in tuples if tup[1] is not None} + if updated_placeholders != cached_placeholders: + _LOGGER.error( + ( + "Validation of translation placeholders for localized (%s) string " + "%s failed" + ), + language, + key, + ) + mismatches.add(key) + + for mismatch in mismatches: + del updated_resources[mismatch] + + return updated_resources + @callback def _build_category_cache( self, @@ -274,12 +311,14 @@ def _build_category_cache( ).setdefault(category, {}) if isinstance(resource, dict): - category_cache.update( - recursive_flatten( - f"component.{component}.{category}.", - resource, - ) + resources_flatten = recursive_flatten( + f"component.{component}.{category}.", + resource, + ) + resources_flatten = self._validate_placeholders( + language, resources_flatten, category_cache ) + category_cache.update(resources_flatten) else: category_cache[f"component.{component}.{category}"] = resource diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index b74c22c9ead4ca..606b90e6005669 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -99,8 +99,7 @@ def __init__( # 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]] = {} diff --git a/homeassistant/loader.py b/homeassistant/loader.py index ce868ab85f3dc1..0a44ccb05c9651 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -131,6 +131,14 @@ class HomeKitDiscoveredIntegration: always_discover: bool +class ZeroconfMatcher(TypedDict, total=False): + """Matcher for zeroconf.""" + + domain: str + name: str + properties: dict[str, str] + + class Manifest(TypedDict, total=False): """Integration manifest. @@ -374,7 +382,7 @@ async def async_get_application_credentials(hass: HomeAssistant) -> list[str]: ] -def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> dict[str, Any]: +def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> ZeroconfMatcher: """Handle backwards compat with zeroconf matchers.""" entry_without_type: dict[str, Any] = entry.copy() del entry_without_type["type"] @@ -396,23 +404,21 @@ def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> dict[str, Any]: else: prop_dict = entry_without_type["properties"] prop_dict[moved_prop] = value.lower() - return entry_without_type + return cast(ZeroconfMatcher, entry_without_type) async def async_get_zeroconf( hass: HomeAssistant, -) -> dict[str, list[dict[str, str | dict[str, str]]]]: +) -> dict[str, list[ZeroconfMatcher]]: """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[ZeroconfMatcher]] = ZEROCONF.copy() # type: ignore[assignment] integrations = await async_get_custom_components(hass) for integration in integrations.values(): if not integration.zeroconf: continue for entry in integration.zeroconf: - data: dict[str, str | dict[str, str]] = {"domain": integration.domain} + data: ZeroconfMatcher = {"domain": integration.domain} if isinstance(entry, dict): typ = entry["type"] data.update(async_process_zeroconf_match_dict(entry)) @@ -1013,9 +1019,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 188fe02698b84e..32189d875a27b7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,37 +1,40 @@ -aiodiscover==1.5.1 -aiohttp-fast-url-dispatcher==0.1.0 -aiohttp-zlib-ng==0.1.1 -aiohttp==3.8.5;python_version<'3.12' -aiohttp==3.9.0b0;python_version>='3.12' +# Automatically generated by gen_requirements_all.py, do not edit + +aiodiscover==1.6.0 +aiohttp-fast-url-dispatcher==0.3.0 +aiohttp-zlib-ng==0.1.3 +aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.11.0 bcrypt==4.0.1 -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 bleak==0.21.1 -bluetooth-adapters==0.16.1 +bluetooth-adapters==0.17.0 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.14.0 +bluetooth-data-tools==1.19.0 +cached_ipaddress==0.3.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==41.0.5 -dbus-fast==2.14.0 +cryptography==41.0.7 +dbus-fast==2.21.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 -home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231030.2 -home-assistant-intents==2023.10.16 -httpx==0.25.0 +habluetooth==2.0.2 +hass-nabucasa==0.75.1 +hassil==1.5.2 +home-assistant-bluetooth==1.11.0 +home-assistant-frontend==20240104.0 +home-assistant-intents==2024.1.2 +httpx==0.26.0 ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 -lru-dict==1.2.0 +lru-dict==1.3.0 mutagen==1.47.0 orjson==3.9.9 packaging>=23.1 @@ -49,24 +52,20 @@ pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 -SQLAlchemy==2.0.23 -typing-extensions>=4.8.0,<5.0 +SQLAlchemy==2.0.25 +typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 +urllib3>=1.26.5,<2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 -yarl==1.9.2 -zeroconf==0.123.0 +yarl==1.9.4 +zeroconf==0.131.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 -# Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 -# Temporary setting an upper bound, to prevent compat issues with urllib3>=2 -# https://github.com/home-assistant/core/issues/97248 -urllib3>=1.26.5,<2 - # Constrain httplib2 to protect against GHSA-93xj-8mrv-444m # https://github.com/advisories/GHSA-93xj-8mrv-444m httplib2>=0.19.0 @@ -106,9 +105,9 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.0.0 +anyio==4.1.0 h11==0.14.0 -httpcore==0.18.0 +httpcore==1.0.2 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation @@ -152,7 +151,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.25.0 +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,3 +181,11 @@ get-mac==1000000000.0.0 # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. charset-normalizer==3.2.0 + +# lxml 5.0.0 currently does not build on alpine 3.18 +# https://bugs.launchpad.net/lxml/+bug/2047718 +lxml==4.9.4 + +# dacite: Ensure we have a version that is able to handle type unions for +# Roborock, NAM, Brother, and GIOS. +dacite>=1.7.0 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 25922ab1f81957..dcccdbccf4053f 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), - "load*": ("homeassistant.config.load_yaml", yaml_loader.load_yaml), + "load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict), "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml), } @@ -290,13 +290,13 @@ def sort_dict_key(val): 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 bf405d5dedab0d..7a7f4323be601f 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -11,14 +11,13 @@ 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_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 @@ def log_error(msg: str, exc_info: Exception | None = None) -> None: 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 @@ def log_error(msg: str, exc_info: Exception | None = None) -> None: 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_path, platform_exception.integration_link + ) if processed_config is None: log_error("Invalid config.") return False @@ -234,8 +278,8 @@ def log_error(msg: str, exc_info: Exception | None = None) -> None: ): _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 @@ def log_error(msg: str, exc_info: Exception | None = None) -> None: 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: @@ -492,7 +538,7 @@ def async_get_loaded_integrations(hass: core.HomeAssistant) -> set[str]: if "." not in component: integrations.add(component) continue - domain, _, platform = component.partition(".") + platform, _, domain = component.partition(".") if domain in BASE_PLATFORMS: integrations.add(platform) return integrations @@ -517,7 +563,7 @@ def async_start_setup( time_taken = dt_util.utcnow() - started for unique, domain in unique_components.items(): del setup_started[unique] - integration = domain.rpartition(".")[-1] + integration = domain.partition(".")[0] if integration in setup_time: setup_time[integration] += time_taken else: diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index bcc7be6226550c..1b8496fe3271a8 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -10,7 +10,7 @@ import logging import threading from traceback import extract_stack -from typing import Any, ParamSpec, TypeVar +from typing import Any, ParamSpec, TypeVar, TypeVarTuple from homeassistant.exceptions import HomeAssistantError @@ -21,6 +21,7 @@ _T = TypeVar("_T") _R = TypeVar("_R") _P = ParamSpec("_P") +_Ts = TypeVarTuple("_Ts") def cancelling(task: Future[Any]) -> bool: @@ -29,7 +30,7 @@ def cancelling(task: Future[Any]) -> bool: def run_callback_threadsafe( - loop: AbstractEventLoop, callback: Callable[..., _T], *args: Any + loop: AbstractEventLoop, callback: Callable[[*_Ts], _T], *args: *_Ts ) -> concurrent.futures.Future[_T]: """Submit a callback object to a given event loop. diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 8e7fc3dc155df4..0ab4ac8c6c1a08 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -7,6 +7,8 @@ import attr +from .scaling import scale_to_ranged_value + class RGBColor(NamedTuple): """RGB hex values.""" @@ -576,6 +578,18 @@ def _white_levels_to_color_temperature( ), min(255, round(brightness * 255)) +def color_xy_to_temperature(x: float, y: float) -> int: + """Convert an xy color to a color temperature in Kelvin. + + Uses McCamy's approximation (https://doi.org/10.1002/col.5080170211), + close enough for uses between 2000 K and 10000 K. + """ + n = (x - 0.3320) / (0.1858 - y) + CCT = 437 * (n**3) + 3601 * (n**2) + 6861 * n + 5517 + + return int(CCT) + + def _clamp(color_component: float, minimum: float = 0, maximum: float = 255) -> float: """Clamp the given color component value between the given min and max values. @@ -732,3 +746,38 @@ def check_valid_gamut(Gamut: GamutType) -> bool: ) return not_on_line and red_valid and green_valid and blue_valid + + +def brightness_to_value(low_high_range: tuple[float, float], brightness: int) -> float: + """Given a brightness_scale convert a brightness to a single value. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 255: 100.0 + 127: ~49.8039 + 10: ~3.9216 + """ + return scale_to_ranged_value((1, 255), low_high_range, brightness) + + +def value_to_brightness(low_high_range: tuple[float, float], value: float) -> int: + """Given a brightness_scale convert a single value to a brightness. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 100: 255 + 50: 128 + 4: 10 + + The value will be clamped between 1..255 to ensure valid value. + """ + return min( + 255, + max(1, round(scale_to_ranged_value(low_high_range, (1, 255), value))), + ) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 34a81728d149d3..81237e1eca6e98 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -5,10 +5,8 @@ from contextlib import suppress import datetime as dt from functools import partial -import platform import re -import time -from typing import Any +from typing import Any, Literal, overload import zoneinfo import ciso8601 @@ -16,7 +14,6 @@ DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.UTC DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC -CLOCK_MONOTONIC_COARSE = 6 # EPOCHORDINAL is not exposed as a constant # https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L12 @@ -180,18 +177,41 @@ def start_of_local_day(dt_or_d: dt.date | dt.datetime | None = None) -> dt.datet # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. # https://github.com/django/django/blob/main/LICENSE +@overload def parse_datetime(dt_str: str) -> dt.datetime | None: + ... + + +@overload +def parse_datetime(dt_str: str, *, raise_on_error: Literal[True]) -> dt.datetime: + ... + + +@overload +def parse_datetime( + dt_str: str, *, raise_on_error: Literal[False] | bool +) -> dt.datetime | None: + ... + + +def parse_datetime(dt_str: str, *, raise_on_error: bool = False) -> dt.datetime | None: """Parse a string and return a datetime.datetime. This function supports time zone offsets. When the input contains one, the output uses a timezone with a fixed offset from UTC. Raises ValueError if the input is well formatted but not a valid datetime. - Returns None if the input isn't well formatted. + + If the input isn't well formatted, returns None if raise_on_error is False + or raises ValueError if it's True. """ + # First try if the string can be parsed by the fast ciso8601 library with suppress(ValueError, IndexError): return ciso8601.parse_datetime(dt_str) + # ciso8601 failed to parse the string, fall back to regex if not (match := DATETIME_RE.match(dt_str)): + if raise_on_error: + raise ValueError return None kws: dict[str, Any] = match.groupdict() if kws["microsecond"]: @@ -476,29 +496,3 @@ def _datetime_ambiguous(dattim: dt.datetime) -> bool: assert dattim.tzinfo is not None opposite_fold = dattim.replace(fold=not dattim.fold) return _datetime_exists(dattim) and dattim.utcoffset() != opposite_fold.utcoffset() - - -def __gen_monotonic_time_coarse() -> partial[float]: - """Return a function that provides monotonic time in seconds. - - This is the coarse version of time_monotonic, which is faster but less accurate. - - Since many arm64 and 32-bit platforms don't support VDSO with time.monotonic - because of errata, we can't rely on the kernel to provide a fast - monotonic time. - - https://lore.kernel.org/lkml/20170404171826.25030-1-marc.zyngier@arm.com/ - """ - # We use a partial here since its implementation is in native code - # which allows us to avoid the overhead of the global lookup - # of CLOCK_MONOTONIC_COARSE. - return partial(time.clock_gettime, CLOCK_MONOTONIC_COARSE) - - -monotonic_time_coarse = time.monotonic -with suppress(Exception): - if ( - platform.system() == "Linux" - and abs(time.monotonic() - __gen_monotonic_time_coarse()()) < 1 - ): - monotonic_time_coarse = __gen_monotonic_time_coarse() diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py new file mode 100644 index 00000000000000..858084bcabc148 --- /dev/null +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -0,0 +1,122 @@ +"""Utility to create classes from which frozen or mutable dataclasses can be derived. + +This module enabled a non-breaking transition from mutable to frozen dataclasses +derived from EntityDescription and sub classes thereof. +""" +from __future__ import annotations + +import dataclasses +import sys +from typing import Any + +from typing_extensions import dataclass_transform + + +def _class_fields(cls: type, kw_only: bool) -> list[tuple[str, Any, Any]]: + """Return a list of dataclass fields. + + Extracted from dataclasses._process_class. + """ + # pylint: disable=protected-access + cls_annotations = cls.__dict__.get("__annotations__", {}) + + cls_fields: list[dataclasses.Field[Any]] = [] + + _dataclasses = sys.modules[dataclasses.__name__] + for name, _type in cls_annotations.items(): + # See if this is a marker to change the value of kw_only. + if dataclasses._is_kw_only(type, _dataclasses) or ( # type: ignore[attr-defined] + isinstance(_type, str) + and dataclasses._is_type( # type: ignore[attr-defined] + _type, + cls, + _dataclasses, + dataclasses.KW_ONLY, + dataclasses._is_kw_only, # type: ignore[attr-defined] + ) + ): + kw_only = True + else: + # Otherwise it's a field of some type. + cls_fields.append(dataclasses._get_field(cls, name, _type, kw_only)) # type: ignore[attr-defined] + + return [(field.name, field.type, field) for field in cls_fields] + + +@dataclass_transform( + field_specifiers=(dataclasses.field, dataclasses.Field), + frozen_default=True, # Set to allow setting frozen in child classes + kw_only_default=True, # Set to allow setting kw_only in child classes +) +class FrozenOrThawed(type): + """Metaclass which which makes classes which behave like a dataclass. + + This allows child classes to be either mutable or frozen dataclasses. + """ + + def _make_dataclass(cls, name: str, bases: tuple[type, ...], kw_only: bool) -> None: + class_fields = _class_fields(cls, kw_only) + dataclass_bases = [] + for base in bases: + dataclass_bases.append(getattr(base, "_dataclass", base)) + cls._dataclass = dataclasses.make_dataclass( + name, class_fields, bases=tuple(dataclass_bases), frozen=True + ) + + def __new__( + mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + frozen_or_thawed: bool = False, + **kwargs: Any, + ) -> Any: + """Pop frozen_or_thawed and store it in the namespace.""" + namespace["_FrozenOrThawed__frozen_or_thawed"] = frozen_or_thawed + return super().__new__(mcs, name, bases, namespace) + + def __init__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + **kwargs: Any, + ) -> None: + """Optionally create a dataclass and store it in cls._dataclass. + + A dataclass will be created if frozen_or_thawed is set, if not we assume the + class will be a real dataclass, i.e. it's decorated with @dataclass. + """ + if not namespace["_FrozenOrThawed__frozen_or_thawed"]: + # This class is a real dataclass, optionally inject the parent's annotations + if all(dataclasses.is_dataclass(base) for base in bases): + # All direct parents are dataclasses, rely on dataclass inheritance + return + # Parent is not a dataclass, inject all parents' annotations + annotations: dict = {} + for parent in cls.__mro__[::-1]: + if parent is object: + continue + annotations |= parent.__annotations__ + cls.__annotations__ = annotations + return + + # First try without setting the kw_only flag, and if that fails, try setting it + try: + cls._make_dataclass(name, bases, False) + except TypeError: + cls._make_dataclass(name, bases, True) + + def __new__(*args: Any, **kwargs: Any) -> object: + """Create a new instance. + + The function has no named arguments to avoid name collisions with dataclass + field names. + """ + cls, *_args = args + if dataclasses.is_dataclass(cls): + return object.__new__(cls) + return cls._dataclass(*_args, **kwargs) + + cls.__init__ = cls._dataclass.__init__ # type: ignore[misc] + cls.__new__ = __new__ # type: ignore[method-assign] diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 7f81c281340218..83ddd373992874 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -33,9 +33,18 @@ class SerializationError(HomeAssistantError): """Error serializing the data to JSON.""" -json_loads: Callable[[bytes | bytearray | memoryview | str], JsonValueType] -json_loads = orjson.loads -"""Parse JSON data.""" +def json_loads(__obj: bytes | bytearray | memoryview | str) -> JsonValueType: + """Parse JSON data. + + This adds a workaround for orjson not handling subclasses of str, + https://github.com/ijl/orjson/issues/445. + """ + # Avoid isinstance overhead for the common case + if type(__obj) not in (bytes, bytearray, memoryview, str) and isinstance( + __obj, str + ): + return orjson.loads(str(__obj)) # type:ignore[no-any-return] + return orjson.loads(__obj) # type:ignore[no-any-return] def json_loads_array(__obj: bytes | bytearray | memoryview | str) -> JsonArrayType: @@ -57,7 +66,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 +89,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 +109,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 44fcaa07067f76..b2ef7330660617 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/logging.py b/homeassistant/util/logging.py index 1328e8ded60142..0f86cde50fe506 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -9,11 +9,12 @@ import logging.handlers import queue import traceback -from typing import Any, TypeVar, cast, overload +from typing import Any, TypeVar, TypeVarTuple, cast, overload from homeassistant.core import HomeAssistant, callback, is_callback _T = TypeVar("_T") +_Ts = TypeVarTuple("_Ts") class HomeAssistantQueueHandler(logging.handlers.QueueHandler): @@ -83,7 +84,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None: listener.start() -def log_exception(format_err: Callable[..., Any], *args: Any) -> None: +def log_exception(format_err: Callable[[*_Ts], Any], *args: *_Ts) -> None: """Log an exception with additional context.""" module = inspect.getmodule(inspect.stack(context=0)[1].frame) if module is not None: @@ -101,23 +102,56 @@ def log_exception(format_err: Callable[..., Any], *args: Any) -> None: logging.getLogger(module_name).error("%s\n%s", friendly_msg, exc_msg) +async def _async_wrapper( + async_func: Callable[[*_Ts], Coroutine[Any, Any, None]], + format_err: Callable[[*_Ts], Any], + *args: *_Ts, +) -> None: + """Catch and log exception.""" + try: + await async_func(*args) + except Exception: # pylint: disable=broad-except + log_exception(format_err, *args) + + +def _sync_wrapper( + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any], *args: *_Ts +) -> None: + """Catch and log exception.""" + try: + func(*args) + except Exception: # pylint: disable=broad-except + log_exception(format_err, *args) + + +@callback +def _callback_wrapper( + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any], *args: *_Ts +) -> None: + """Catch and log exception.""" + try: + func(*args) + except Exception: # pylint: disable=broad-except + log_exception(format_err, *args) + + @overload def catch_log_exception( - func: Callable[..., Coroutine[Any, Any, Any]], format_err: Callable[..., Any] -) -> Callable[..., Coroutine[Any, Any, None]]: + func: Callable[[*_Ts], Coroutine[Any, Any, Any]], format_err: Callable[[*_Ts], Any] +) -> Callable[[*_Ts], Coroutine[Any, Any, None]]: ... @overload def catch_log_exception( - func: Callable[..., Any], format_err: Callable[..., Any] -) -> Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]: + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any] +) -> Callable[[*_Ts], None] | Callable[[*_Ts], Coroutine[Any, Any, None]]: ... def catch_log_exception( - func: Callable[..., Any], format_err: Callable[..., Any] -) -> Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]: + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any] +) -> Callable[[*_Ts], None] | Callable[[*_Ts], Coroutine[Any, Any, None]]: """Decorate a function func to catch and log exceptions. If func is a coroutine function, a coroutine function will be returned. @@ -126,45 +160,24 @@ def catch_log_exception( # Check for partials to properly determine if coroutine function check_func = func while isinstance(check_func, partial): - check_func = check_func.func + check_func = check_func.func # type: ignore[unreachable] # false positive - wrapper_func: Callable[..., None] | Callable[..., Coroutine[Any, Any, None]] if asyncio.iscoroutinefunction(check_func): - async_func = cast(Callable[..., Coroutine[Any, Any, None]], func) - - @wraps(async_func) - async def async_wrapper(*args: Any) -> None: - """Catch and log exception.""" - try: - await async_func(*args) - except Exception: # pylint: disable=broad-except - log_exception(format_err, *args) - - wrapper_func = async_wrapper - - else: - - @wraps(func) - def wrapper(*args: Any) -> None: - """Catch and log exception.""" - try: - func(*args) - except Exception: # pylint: disable=broad-except - log_exception(format_err, *args) + async_func = cast(Callable[[*_Ts], Coroutine[Any, Any, None]], func) + return wraps(async_func)(partial(_async_wrapper, async_func, format_err)) # type: ignore[return-value] - if is_callback(check_func): - wrapper = callback(wrapper) + if is_callback(check_func): + return wraps(func)(partial(_callback_wrapper, func, format_err)) # type: ignore[return-value] - wrapper_func = wrapper - return wrapper_func + return wraps(func)(partial(_sync_wrapper, func, format_err)) # type: ignore[return-value] def catch_log_coro_exception( - target: Coroutine[Any, Any, _T], format_err: Callable[..., Any], *args: Any + target: Coroutine[Any, Any, _T], format_err: Callable[[*_Ts], Any], *args: *_Ts ) -> Coroutine[Any, Any, _T | None]: """Decorate a coroutine to catch and log exceptions.""" - async def coro_wrapper(*args: Any) -> _T | None: + async def coro_wrapper(*args: *_Ts) -> _T | None: """Catch and log exception.""" try: return await target @@ -176,7 +189,7 @@ async def coro_wrapper(*args: Any) -> _T | None: def async_create_catching_coro( - target: Coroutine[Any, Any, _T] + target: Coroutine[Any, Any, _T], ) -> Coroutine[Any, Any, _T | None]: """Wrap a coroutine to catch and log exceptions. diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index ca5931b2670c73..cc4835022d3b48 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -3,6 +3,13 @@ from typing import TypeVar +from .scaling import ( # noqa: F401 + int_states_in_range, + scale_ranged_value_to_int_range, + scale_to_ranged_value, + states_in_range, +) + _T = TypeVar("_T") @@ -69,8 +76,7 @@ def ranged_value_to_percentage( (1,255), 127: 50 (1,255), 10: 4 """ - offset = low_high_range[0] - 1 - return int(((value - offset) * 100) // states_in_range(low_high_range)) + return scale_ranged_value_to_int_range(low_high_range, (1, 100), value) def percentage_to_ranged_value( @@ -87,15 +93,4 @@ def percentage_to_ranged_value( (1,255), 50: 127.5 (1,255), 4: 10.2 """ - offset = low_high_range[0] - 1 - return states_in_range(low_high_range) * percentage / 100 + offset - - -def states_in_range(low_high_range: tuple[float, float]) -> float: - """Given a range of low and high values return how many states exist.""" - return low_high_range[1] - low_high_range[0] + 1 - - -def int_states_in_range(low_high_range: tuple[float, float]) -> int: - """Given a range of low and high values return how many integer states exist.""" - return int(states_in_range(low_high_range)) + return scale_to_ranged_value((1, 100), low_high_range, percentage) diff --git a/homeassistant/util/scaling.py b/homeassistant/util/scaling.py new file mode 100644 index 00000000000000..70e2ac2516acd5 --- /dev/null +++ b/homeassistant/util/scaling.py @@ -0,0 +1,62 @@ +"""Scaling util functions.""" +from __future__ import annotations + + +def scale_ranged_value_to_int_range( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> int: + """Given a range of low and high values convert a single value to another range. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), (1,100), 255: 100 + (1,255), (1,100), 127: 49 + (1,255), (1,100), 10: 3 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return int( + (value - source_offset) + * states_in_range(target_low_high_range) + // states_in_range(source_low_high_range) + + target_offset + ) + + +def scale_to_ranged_value( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> float: + """Given a range of low and high values convert a single value to another range. + + Do not include 0 in a range if 0 means off, + e.g. for brightness or fan speed. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), 255: 100 + (1,255), 127: ~49.8039 + (1,255), 10: ~3.9216 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return (value - source_offset) * ( + states_in_range(target_low_high_range) + ) / states_in_range(source_low_high_range) + target_offset + + +def states_in_range(low_high_range: tuple[float, float]) -> float: + """Given a range of low and high values return how many states exist.""" + return low_high_range[1] - low_high_range[0] + 1 + + +def int_states_in_range(low_high_range: tuple[float, float]) -> int: + """Given a range of low and high values return how many integer states exist.""" + return int(states_in_range(low_high_range)) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 2b50371606384b..6bfbec88a336d9 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -61,16 +61,11 @@ class SSLCipherList(StrEnum): @cache -def create_no_verify_ssl_context( - ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, -) -> ssl.SSLContext: - """Return an SSL context that does not verify the server certificate. +def _create_no_verify_ssl_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: + # This is a copy of aiohttp's create_default_context() function, with the + # ssl verify turned off. + # https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 - This is a copy of aiohttp's create_default_context() function, with the - ssl verify turned off. - - https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 - """ sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) sslcontext.check_hostname = False sslcontext.verify_mode = ssl.CERT_NONE @@ -84,12 +79,16 @@ def create_no_verify_ssl_context( return sslcontext -@cache -def client_context( +def create_no_verify_ssl_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: - """Return an SSL context for making requests.""" + """Return an SSL context that does not verify the server certificate.""" + + return _create_no_verify_ssl_context(ssl_cipher_list=ssl_cipher_list) + +@cache +def _client_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: # Reuse environment variable definition from requests, since it's already a # requirement. If the environment variable has no value, fall back to using # certs from certifi package. @@ -104,6 +103,14 @@ def client_context( return sslcontext +def client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an SSL context for making requests.""" + + return _client_context(ssl_cipher_list=ssl_cipher_list) + + # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = client_context() _DEFAULT_NO_VERIFY_SSL_CONTEXT = create_no_verify_ssl_context() diff --git a/homeassistant/util/ulid.py b/homeassistant/util/ulid.py index 643286cedb97eb..818b8015549963 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_to_bytes - -__all__ = ["ulid", "ulid_hex", "ulid_at_time", "ulid_to_bytes", "bytes_to_ulid"] +from ulid_transform import ( + bytes_to_ulid, + ulid_at_time, + ulid_hex, + ulid_now, + ulid_to_bytes, +) + +__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/__init__.py b/homeassistant/util/yaml/__init__.py index b3f1b7ecd43768..fe4f01677cdc2f 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -2,7 +2,14 @@ from .const import SECRET_YAML from .dumper import dump, save_yaml from .input import UndefinedSubstitution, extract_inputs, substitute -from .loader import Secrets, load_yaml, parse_yaml, secret_yaml +from .loader import ( + Secrets, + YamlTypeError, + load_yaml, + load_yaml_dict, + parse_yaml, + secret_yaml, +) from .objects import Input __all__ = [ @@ -11,7 +18,9 @@ "dump", "save_yaml", "Secrets", + "YamlTypeError", "load_yaml", + "load_yaml_dict", "secret_yaml", "parse_yaml", "UndefinedSubstitution", diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 8a8822ab17f02a..97dbb7d8789a0b 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 @@ ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -34,6 +36,10 @@ _LOGGER = logging.getLogger(__name__) +class YamlTypeError(HomeAssistantError): + """Raised by load_yaml_dict if top level data is not a dict.""" + + class Secrets: """Store secrets while loading YAML.""" @@ -135,6 +141,37 @@ def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: 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.""" @@ -144,10 +181,43 @@ def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: 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: +def load_yaml( + fname: str | os.PathLike[str], secrets: Secrets | None = None +) -> JSON_TYPE | None: """Load a YAML file.""" try: with open(fname, encoding="utf-8") as conf_file: @@ -157,6 +227,22 @@ def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: raise HomeAssistantError(exc) from exc +def load_yaml_dict( + fname: str | os.PathLike[str], secrets: Secrets | None = None +) -> dict: + """Load a YAML file and ensure the top level is a dict. + + Raise if the top level is not a dict. + Return an empty dict if the file is empty. + """ + loaded_yaml = load_yaml(fname, secrets) + if loaded_yaml is None: + loaded_yaml = {} + if not isinstance(loaded_yaml, dict): + raise YamlTypeError(f"YAML file {fname} does not contain a dict") + return loaded_yaml + + def parse_yaml( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: @@ -191,12 +277,7 @@ def _parse_yaml( secrets: Secrets | None = None, ) -> JSON_TYPE: """Load a YAML file.""" - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return ( - yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] - or NodeDictClass() - ) + return yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] @overload @@ -230,13 +311,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 + 1) + 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 @@ -244,7 +326,10 @@ def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """ fname = os.path.join(os.path.dirname(loader.get_name()), node.value) try: - return _add_reference(load_yaml(fname, loader.secrets), loader, node) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + loaded_yaml = NodeDictClass() + return _add_reference(loaded_yaml, loader, node) except FileNotFoundError as exc: raise HomeAssistantError( f"{node.start_mark}: Unable to read file {fname}." @@ -274,7 +359,10 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDi filename = os.path.splitext(os.path.basename(fname))[0] if os.path.basename(fname) == SECRET_YAML: continue - mapping[filename] = load_yaml(fname, loader.secrets) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + continue + mapping[filename] = loaded_yaml return _add_reference(mapping, loader, node) @@ -299,9 +387,10 @@ def _include_dir_list_yaml( """Load multiple files from directory as a list.""" loc = os.path.join(os.path.dirname(loader.get_name()), node.value) return [ - load_yaml(f, loader.secrets) + loaded_yaml for f in _find_files(loc, "*.yaml") if os.path.basename(f) != SECRET_YAML + and (loaded_yaml := load_yaml(f, loader.secrets)) is not None ] @@ -338,7 +427,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 diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index b2320a74d2c2e2..6aedc85cf6076d 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/machine/raspberrypi b/machine/raspberrypi index 3cce504661e38d..2ed3b3c8e442bf 100644 --- a/machine/raspberrypi +++ b/machine/raspberrypi @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi camera binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi2 b/machine/raspberrypi2 index c49db40b40875e..2ed3b3c8e442bf 100644 --- a/machine/raspberrypi2 +++ b/machine/raspberrypi2 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi3 b/machine/raspberrypi3 index c49db40b40875e..2ed3b3c8e442bf 100644 --- a/machine/raspberrypi3 +++ b/machine/raspberrypi3 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi3-64 b/machine/raspberrypi3-64 index c49db40b40875e..2ed3b3c8e442bf 100644 --- a/machine/raspberrypi3-64 +++ b/machine/raspberrypi3-64 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi4 b/machine/raspberrypi4 index c49db40b40875e..2ed3b3c8e442bf 100644 --- a/machine/raspberrypi4 +++ b/machine/raspberrypi4 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi4-64 b/machine/raspberrypi4-64 index c49db40b40875e..2ed3b3c8e442bf 100644 --- a/machine/raspberrypi4-64 +++ b/machine/raspberrypi4-64 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi5-64 b/machine/raspberrypi5-64 new file mode 100644 index 00000000000000..2ed3b3c8e442bf --- /dev/null +++ b/machine/raspberrypi5-64 @@ -0,0 +1,8 @@ +ARG \ + BUILD_FROM + +FROM $BUILD_FROM + +RUN apk --no-cache add \ + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/yellow b/machine/yellow index c49db40b40875e..2ed3b3c8e442bf 100644 --- a/machine/yellow +++ b/machine/yellow @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/mypy.ini b/mypy.ini index 41a02600d94c19..53f5b0715ce4b8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -180,6 +180,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.acmeda.*] +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.actiontec.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -190,6 +200,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.adax.*] +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.adguard.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -230,6 +250,46 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airnow.*] +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.airq.*] +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.airthings.*] +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.airthings_ble.*] +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.airvisual.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -240,6 +300,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airvisual_pro.*] +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.airzone.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -300,6 +370,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.alpha_vantage.*] +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.amazon_polly.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -310,6 +390,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.amberelectric.*] +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.ambiclimate.*] +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.ambient_station.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -350,6 +450,46 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.android_ip_webcam.*] +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.androidtv.*] +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.androidtv_remote.*] +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.anel_pwrctrl.*] +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.anova.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -370,6 +510,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.apache_kafka.*] +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.apcupsd.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -380,6 +530,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.apprise.*] +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.aprs.*] +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.aqualogic.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -390,6 +560,66 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aquostv.*] +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.aranet.*] +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.arcam_fmj.*] +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.arris_tg2492lg.*] +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.aruba.*] +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.arwn.*] +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.aseko_pool_live.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -410,6 +640,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.asterisk_cdr.*] +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.asterisk_mbox.*] +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.asuswrt.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -450,6 +700,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.axis.*] +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.backup.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -510,6 +770,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.blue_current.*] +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.blueprint.*] +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.bluetooth.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -660,6 +940,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.co2signal.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.command_line.*] +check_untyped_defs = true +disallow_incomplete_defs = true +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.configurator.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -670,6 +970,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.counter.*] +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.cover.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -700,6 +1010,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.date.*] +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.datetime.*] +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.deconz.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -931,6 +1261,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.enigma2.*] +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.esphome.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -961,6 +1301,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.evohome.*] +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.faa_delays.*] +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.fan.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1031,6 +1391,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.flexit_bacnet.*] +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.flux_led.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1261,6 +1631,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.holiday.*] +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.homeassistant.exposed_entities] check_untyped_defs = true disallow_incomplete_defs = true @@ -1461,6 +1841,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.humidifier.*] +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.hydrawise.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1781,6 +2171,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 @@ -2031,6 +2431,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.motionmount.*] +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.mqtt.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2391,6 +2801,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pushbullet.*] +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.pvoutput.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2741,6 +3161,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.siren.*] +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.skybell.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2882,6 +3312,36 @@ warn_return_any = true warn_unreachable = true no_implicit_reexport = true +[mypy-homeassistant.components.streamlabswater.*] +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.stt.*] +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.suez_water.*] +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.sun.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2982,6 +3442,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tailwind.*] +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.tami4.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3012,6 +3482,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tedee.*] +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.text.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3062,6 +3542,36 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.time.*] +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.time_date.*] +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.todo.*] +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.tolo.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3283,6 +3793,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.valve.*] +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.velbus.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3313,6 +3833,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.wake_word.*] +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.wallbox.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f43dd9b667209c..b2620dd3e1e1ba 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -587,10 +587,6 @@ class ClassTypeHintMatch: function_name="state_attributes", return_type=["dict[str, Any]", None], ), - TypeHintMatch( - function_name="device_state_attributes", - return_type=["Mapping[str, Any]", None], - ), TypeHintMatch( function_name="extra_state_attributes", return_type=["Mapping[str, Any]", None], @@ -631,10 +627,6 @@ class ClassTypeHintMatch: function_name="supported_features", return_type=["int", None], ), - TypeHintMatch( - function_name="context_recent_time", - return_type="timedelta", - ), TypeHintMatch( function_name="entity_registry_enabled_default", return_type="bool", diff --git a/pyproject.toml b/pyproject.toml index 550cafc4146274..f611cc73f1b100 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0.dev0" +version = "2024.2.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -23,11 +23,10 @@ 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.1.0", - "aiohttp-zlib-ng==0.1.1", + "aiohttp-fast-url-dispatcher==0.3.0", + "aiohttp-zlib-ng==0.1.3", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", @@ -37,14 +36,14 @@ dependencies = [ "ciso8601==2.3.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.25.0", - "home-assistant-bluetooth==1.10.4", + "httpx==0.26.0", + "home-assistant-bluetooth==1.11.0", "ifaddr==0.2.0", "Jinja2==3.1.2", - "lru-dict==1.2.0", + "lru-dict==1.3.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==41.0.5", + "cryptography==41.0.7", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", "orjson==3.9.9", @@ -53,11 +52,15 @@ dependencies = [ "python-slugify==4.0.1", "PyYAML==6.0.1", "requests==2.31.0", - "typing-extensions>=4.8.0,<5.0", + "typing-extensions>=4.9.0,<5.0", "ulid-transform==0.9.0", + # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 + # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 + # https://github.com/home-assistant/core/issues/97248 + "urllib3>=1.26.5,<2", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", - "yarl==1.9.2", + "yarl==1.9.4", ] [project.urls] @@ -79,9 +82,6 @@ include-package-data = true [tool.setuptools.packages.find] include = ["homeassistant*"] -[tool.black] -extend-exclude = "/generated/" - [tool.pylint.MAIN] py-version = "3.11" ignore = [ @@ -128,7 +128,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 @@ -439,13 +439,13 @@ filterwarnings = [ "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.5/elkm1_lib/util.py#L8-L19 + # https://github.com/gwww/elkm1/blob/2.2.6/elkm1_lib/util.py#L8-L19 "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", # https://github.com/michaeldavie/env_canada/blob/v0.6.0/env_canada/ec_cache.py "ignore:Inheritance class CacheClientSession from ClientSession is discouraged:DeprecationWarning:env_canada.ec_cache", # https://github.com/allenporter/ical/pull/215 - v5.0.0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", - # https://github.com/bachya/regenmaschine/blob/2023.08.0/regenmaschine/client.py#L51 + # https://github.com/bachya/regenmaschine/blob/2023.12.0/regenmaschine/client.py#L57 "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", # -- Setuptools DeprecationWarnings @@ -455,13 +455,6 @@ filterwarnings = [ "ignore:Deprecated call to `pkg_resources.declare_namespace\\('google.*'\\)`:DeprecationWarning:google.rpc", # -- tracked upstream / open PRs - # https://github.com/caronc/apprise/issues/659 - v1.4.5 - "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", - # https://github.com/kiorky/croniter/issues/49 - v1.4.1 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", - # https://github.com/spulec/freezegun/issues/508 - v1.2.2 - # https://github.com/spulec/freezegun/pull/511 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:freezegun.api", # https://github.com/influxdata/influxdb-client-python/issues/603 - v1.37.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 @@ -471,9 +464,8 @@ filterwarnings = [ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", # https://github.com/PythonCharmers/python-future/issues/488 - v0.18.3 "ignore:the imp module is deprecated in favour of importlib and slated for removal in Python 3.12:DeprecationWarning:future.standard_library", - # https://github.com/frenck/python-toonapi/pull/9 - v0.2.1 - 2021-09-23 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:toonapi.models", - # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.2 + # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 + # https://github.com/foxel/python_ndms2_client/pull/8 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", # https://github.com/pytest-dev/pytest-cov/issues/557 - v4.1.0 # Should resolve itself once pytest-xdist 4.0 is released and the option is removed @@ -482,19 +474,15 @@ filterwarnings = [ # -- fixed, waiting for release / update # https://github.com/ludeeus/aiogithubapi/pull/208 - >=23.9.0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiogithubapi.namespaces.events", - # https://github.com/bachya/aiopurpleair/pull/200 - >2023.08.0 + # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", - # https://github.com/scrapinghub/dateparser/pull/1179 - >1.1.8 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:dateparser.timezone_parser", - # https://github.com/zopefoundation/DateTime/pull/55 - >5.2 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:DateTime.pytz_support", - # https://github.com/kurtmckee/feedparser/issues/330 - >6.0.10 - "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:feedparser.encodings", + # https://github.com/kiorky/croniter/pull/52 - >=2.0.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/bachya/pytile/pull/280 - >2023.08.0 + # https://github.com/bachya/pytile/pull/280 - >=2023.10.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytile.tile", # https://github.com/rytilahti/python-miio/pull/1809 - >0.5.12 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", @@ -503,14 +491,12 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", # Fixed upstream in python-telegram-bot - >=20.0 "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 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:xiaomi_ble.parser", + # https://github.com/mvantellingen/python-zeep/pull/1364 - >4.2.1 + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:zeep.utils", # -- not helpful # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 @@ -518,10 +504,8 @@ filterwarnings = [ # -- other # Locale changes might take some time to resolve upstream - "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:homematicip.base.base_connection", - "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:micloud.micloud", - # https://github.com/protocolbuffers/protobuf - v4.24.4 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:google.protobuf.internal.well_known_types", + "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", + # https://github.com/protocolbuffers/protobuf - v4.25.1 "ignore:Type google._upb._message.(Message|Scalar)MapContainer uses PyType_Spec with a metaclass that has custom tp_new. .* Python 3.14:DeprecationWarning", # https://github.com/googleapis/google-auth-library-python/blob/v2.23.3/google/auth/_helpers.py#L95 - v2.23.3 "ignore:datetime.*utcnow\\(\\) is deprecated:DeprecationWarning:google.auth._helpers", @@ -529,6 +513,12 @@ 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/lidatong/dataclasses-json/issues/328 + # https://github.com/lidatong/dataclasses-json/pull/351 + "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", + # Fixed for Python 3.12 + # https://github.com/lextudio/pysnmp/issues/10 + "ignore:The asyncore module is deprecated and will be removed in Python 3.12:DeprecationWarning:pysnmp.carrier.asyncore.base", # 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", @@ -593,7 +583,6 @@ select = [ "G", # flake8-logging-format "I", # isort "ICN001", # import concentions; {name} should be imported as {asname} - "ISC001", # Implicitly concatenated string literals on one line "N804", # First argument of a class method should be named cls "N805", # First argument of a method should be named self "N815", # Variable {name} in class scope should not be mixedCase @@ -669,6 +658,21 @@ ignore = [ # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q000", + "Q001", + "Q002", + "Q003", + "COM812", + "COM819", + "ISC001", + "ISC002", ] [tool.ruff.flake8-import-conventions.extend-aliases] diff --git a/requirements.txt b/requirements.txt index 1ca4643a7474a8..55cbdc317306b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,12 @@ +# 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.1.0 -aiohttp-zlib-ng==0.1.1 +aiohttp-fast-url-dispatcher==0.3.0 +aiohttp-zlib-ng==0.1.3 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 @@ -13,13 +14,13 @@ awesomeversion==23.11.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 -httpx==0.25.0 -home-assistant-bluetooth==1.10.4 +httpx==0.26.0 +home-assistant-bluetooth==1.11.0 ifaddr==0.2.0 Jinja2==3.1.2 -lru-dict==1.2.0 +lru-dict==1.3.0 PyJWT==2.8.0 -cryptography==41.0.5 +cryptography==41.0.7 pyOpenSSL==23.2.0 orjson==3.9.9 packaging>=23.1 @@ -27,8 +28,9 @@ pip>=21.3.1 python-slugify==4.0.1 PyYAML==6.0.1 requests==2.31.0 -typing-extensions>=4.8.0,<5.0 +typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 +urllib3>=1.26.5,<2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 -yarl==1.9.2 +yarl==1.9.4 diff --git a/requirements_all.txt b/requirements_all.txt index 6d2d8b1ee21efd..023ddd084a39f2 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.6 +AEMET-OpenData==0.4.7 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.17 +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 @@ -29,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.9.1 # homeassistant.components.tasmota -HATasmota==0.7.3 +HATasmota==0.8.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -46,7 +45,7 @@ Mastodon.py==1.5.1 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.7 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -55,7 +54,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==13.0.7 +PyChromecast==13.0.8 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -97,7 +96,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.1 +PySwitchbot==0.43.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -113,26 +112,23 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.28.1 +PyViCare==2.32.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 # homeassistant.components.rachio -RachioPy==1.0.3 +RachioPy==1.1.0 # 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.23 +SQLAlchemy==2.0.25 # 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,17 +185,20 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.2.4 +aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.6 +aioairzone-cloud==0.3.8 # homeassistant.components.airzone -aioairzone==0.6.9 +aioairzone==0.7.2 # homeassistant.components.ambient_station aioambient==2023.04.0 +# homeassistant.components.apcupsd +aioapcaccess==0.4.2 + # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -216,10 +215,10 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.3.0 +aiocomelit==0.7.0 # homeassistant.components.dhcp -aiodiscover==1.5.1 +aiodiscover==1.6.0 # homeassistant.components.dnsip aiodns==3.0.0 @@ -233,11 +232,14 @@ aioeagle==1.1.0 # homeassistant.components.ecowitt aioecowitt==2023.5.0 +# homeassistant.components.co2signal +aioelectricitymaps==0.1.6 + # homeassistant.components.emonitor aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.4.0 +aioesphomeapi==21.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -255,13 +257,13 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.9 +aiohomekit==3.1.2 # homeassistant.components.http -aiohttp-fast-url-dispatcher==0.1.0 +aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.1 +aiohttp-zlib-ng==0.1.3 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -283,10 +285,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 @@ -319,7 +321,7 @@ aioopenexchangerates==0.4.0 aiopegelonline==0.0.6 # homeassistant.components.acmeda -aiopulse==0.4.3 +aiopulse==0.4.4 # homeassistant.components.purpleair aiopurpleair==2022.12.1 @@ -354,7 +356,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.0.0 +aioshelly==7.0.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -375,16 +377,16 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==65 +aiounifi==68 # 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 @@ -393,7 +395,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 @@ -434,11 +436,8 @@ anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 -# homeassistant.components.apcupsd -apcaccess==0.0.13 - # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.2 # homeassistant.components.apprise apprise==1.6.0 @@ -464,16 +463,13 @@ 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 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 @@ -482,7 +478,7 @@ asyncinotify==4.0.2 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.3.7 +asyncsleepiq==1.4.1 # homeassistant.components.aten_pe # atenpdu==0.3.2 @@ -508,6 +504,9 @@ azure-eventhub==5.11.1 # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.baidu baidu-aip==1.6.6 @@ -527,16 +526,19 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.8 +bellows==0.37.6 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.2 +bimmer-connected[china]==0.14.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 +# homeassistant.components.esphome +bleak-esphome==0.4.1 + # homeassistant.components.bluetooth -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 # homeassistant.components.bluetooth bleak==0.21.1 @@ -545,11 +547,14 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.3 +blinkpy==0.22.4 # homeassistant.components.bitcoin blockchain==1.4.4 +# homeassistant.components.blue_current +bluecurrent-api==1.0.6 + # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 @@ -558,23 +563,22 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.1 +bluetooth-adapters==0.17.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth -# homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.14.0 +bluetooth-data-tools==1.19.0 # homeassistant.components.bond bond-async==0.2.1 # homeassistant.components.bosch_shc -boschshcpy==0.2.57 +boschshcpy==0.2.75 # homeassistant.components.amazon_polly # homeassistant.components.route53 @@ -584,10 +588,10 @@ boto3==1.28.17 broadlink==0.18.3 # homeassistant.components.brother -brother==2.3.0 +brother==3.0.0 # homeassistant.components.brottsplatskartan -brottsplatskartan==0.0.1 +brottsplatskartan==1.0.5 # homeassistant.components.brunt brunt==1.2.0 @@ -596,7 +600,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.2.0 +bthome-ble==3.3.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -607,8 +611,11 @@ btsmarthub-devicelist==0.2.3 # homeassistant.components.buienradar buienradar==1.0.5 +# homeassistant.components.dhcp +cached_ipaddress==0.3.0 + # homeassistant.components.caldav -caldav==1.3.6 +caldav==1.3.8 # homeassistant.components.circuit circuit-webhook==1.0.1 @@ -637,7 +644,6 @@ concord232==0.15 # homeassistant.components.upc_connect connect-box==0.2.8 -# homeassistant.components.eq3btsmart # homeassistant.components.xiaomi_miio construct==2.10.68 @@ -660,7 +666,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.21.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -685,8 +691,11 @@ demetriek==0.4.0 # homeassistant.components.denonavr denonavr==0.11.4 +# homeassistant.components.devialet +devialet==1.4.5 + # homeassistant.components.devolo_home_control -devolo-home-control-api==0.18.2 +devolo-home-control-api==0.18.3 # homeassistant.components.devolo_home_network devolo-plc-api==1.4.1 @@ -706,6 +715,9 @@ dovado==0.4.1 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 +# homeassistant.components.drop_connect +dropmqttapi==1.0.2 + # homeassistant.components.dsmr dsmr-parser==1.3.1 @@ -725,7 +737,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.3.0 +easyenergy==2.1.0 # homeassistant.components.ebusd ebusdpy==0.0.17 @@ -737,7 +749,7 @@ ecoaliface==0.4.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.1.0 +elgato==5.1.1 # homeassistant.components.eliqonline eliqonline==1.2.2 @@ -758,7 +770,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.5.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 @@ -791,7 +803,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.6 +evohome-async==0.4.15 # homeassistant.components.faa_delays faadelays==2023.9.1 @@ -804,7 +816,7 @@ faadelays==2023.9.1 fastdotcom==0.0.3 # homeassistant.components.feedreader -feedparser==6.0.10 +feedparser==6.0.11 # homeassistant.components.file file-read-backwards==2.0.0 @@ -824,6 +836,9 @@ fixerio==1.0.0a0 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 @@ -857,13 +872,13 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.0 +gardena-bluetooth==1.4.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==5.0.0 +gcal-sync==6.0.3 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -888,16 +903,16 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.8.2 +getmac==0.9.4 # homeassistant.components.gios -gios==3.2.1 +gios==3.2.2 # homeassistant.components.gitter gitterpy==0.1.7 # homeassistant.components.glances -glances-api==0.4.3 +glances-api==0.5.0 # homeassistant.components.goalzero goalzero==0.2.2 @@ -916,7 +931,7 @@ google-cloud-pubsub==2.13.11 google-cloud-texttospeech==2.12.3 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.1.0 +google-generativeai==0.3.1 # homeassistant.components.nest google-nest-sdm==3.0.3 @@ -927,6 +942,9 @@ googlemaps==2.5.1 # homeassistant.components.slide goslide-api==0.5.1 +# homeassistant.components.tailwind +gotailwind==0.2.2 + # homeassistant.components.govee_ble govee-ble==0.24.0 @@ -946,7 +964,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 @@ -979,14 +997,17 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==2.0.2 + # homeassistant.components.cloud -hass-nabucasa==0.74.0 +hass-nabucasa==0.75.1 # homeassistant.components.splunk hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.2.5 +hassil==1.5.2 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -1012,14 +1033,15 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday -holidays==0.35 +holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20231030.2 +home-assistant-frontend==20240104.0 # homeassistant.components.conversation -home-assistant-intents==2023.10.16 +home-assistant-intents==2024.1.2 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -1037,13 +1059,13 @@ 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 # homeassistant.components.iammeter -iammeter==0.1.7 +iammeter==0.2.1 # homeassistant.components.iaqualink iaqualink==0.5.0 @@ -1056,13 +1078,13 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.1.0 +ical==6.1.1 # homeassistant.components.ping icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.3 +idasen-ha==2.4 # homeassistant.components.network ifaddr==0.2.0 @@ -1130,9 +1152,6 @@ kiwiki-client==0.1.1 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 @@ -1167,7 +1186,7 @@ librouteros==3.2.0 libsoundtouch==0.8 # homeassistant.components.life360 -life360==6.0.0 +life360==6.0.1 # homeassistant.components.osramlightify lightify==1.0.7.3 @@ -1178,6 +1197,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 @@ -1197,13 +1219,13 @@ loqedAPI==2.1.8 luftdaten==0.7.4 # homeassistant.components.lupusec -lupupy==0.3.0 +lupupy==0.3.1 # homeassistant.components.lw12wifi lw12==0.9.2 # homeassistant.components.scrape -lxml==4.9.3 +lxml==4.9.4 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 @@ -1236,7 +1258,7 @@ messagebird==1.2.0 meteoalertapi==0.3.0 # homeassistant.components.meteo_france -meteofrance-api==1.2.0 +meteofrance-api==1.3.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -1248,7 +1270,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.8 # homeassistant.components.minio minio==7.1.12 @@ -1260,10 +1282,10 @@ 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 +motionblinds==0.6.19 # homeassistant.components.motioneye motioneye-client==0.3.14 @@ -1277,6 +1299,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 @@ -1293,7 +1318,7 @@ netdata==1.1.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.2.1 +nettigo-air-monitor==2.2.2 # homeassistant.components.neurio_energy neurio==0.3.1 @@ -1308,10 +1333,10 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==2.0.1 +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 @@ -1358,7 +1383,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 @@ -1382,7 +1407,7 @@ open-garage==0.2.0 open-meteo==0.3.1 # homeassistant.components.openai_conversation -openai==0.27.2 +openai==1.3.8 # homeassistant.components.opencv # opencv-python-headless==4.6.0.66 @@ -1400,7 +1425,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==3.2.7 +openwebifpy==4.0.4 # homeassistant.components.luci openwrt-luci-rpc==1.1.16 @@ -1409,7 +1434,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.39 +opower==0.1.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1418,13 +1443,16 @@ oralb-ble==0.17.6 oru==0.1.11 # homeassistant.components.orvibo -orvibo==1.1.1 +orvibo==1.1.2 + +# 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 @@ -1450,7 +1478,6 @@ pescea==1.0.12 # homeassistant.components.aruba # homeassistant.components.cisco_ios # homeassistant.components.pandora -# homeassistant.components.unifi_direct pexpect==4.6.0 # homeassistant.components.modem_callerid @@ -1472,7 +1499,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.2 +plugwise==0.35.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1487,7 +1514,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 @@ -1503,7 +1530,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.6 +psutil==5.9.7 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 @@ -1518,11 +1545,17 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.1.0 +pvo==2.1.1 + +# homeassistant.components.aosmith +py-aosmith==1.0.1 # homeassistant.components.canary py-canary==0.5.3 +# homeassistant.components.ccm15 +py-ccm15==0.0.9 + # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 @@ -1566,7 +1599,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.11.1 +pyDuotecno==2024.1.1 # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1608,11 +1641,14 @@ pyairnow==1.2.1 # homeassistant.components.airvisual_pro pyairvisual==2023.08.1 +# homeassistant.components.asuswrt +pyasuswrt==0.1.21 + # homeassistant.components.atag pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.6.0 +pyatmo==8.0.2 # homeassistant.components.apple_tv pyatv==0.14.3 @@ -1672,7 +1708,7 @@ pydaikin==2.11.1 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==113 +pydeconz==114 # homeassistant.components.delijn pydelijn==1.1.0 @@ -1687,7 +1723,7 @@ pydiscovergy==2.0.5 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2023.11.0 +pydrawise==2024.1.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1708,7 +1744,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.2 +pyenphase==1.15.2 # homeassistant.components.envisalink pyenvisalink==4.6 @@ -1768,7 +1804,7 @@ pyhaversion==22.8.0 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.14 +pyhiveapi==0.5.16 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -1783,7 +1819,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.1 +pyinsteon==1.5.3 # homeassistant.components.intesishome pyintesishome==1.8.0 @@ -1825,7 +1861,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 @@ -1852,7 +1888,7 @@ pylgnetcast==0.3.7 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.5.0 +pylitejet==0.6.2 # homeassistant.components.litterrobot pylitterbot==2023.4.9 @@ -1912,7 +1948,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,6 +1974,9 @@ pyopnsense==0.4.0 # homeassistant.components.opple pyoppleio-legacy==1.0.8 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 @@ -1947,7 +1986,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.0 +pyoverkiz==1.13.3 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1977,7 +2016,7 @@ pyprof2calltree==1.4.5 pyprosegur==0.0.9 # homeassistant.components.prusalink -pyprusalink==1.1.0 +pyprusalink==2.0.0 # homeassistant.components.ps4 pyps4-2ndscreen==1.3.1 @@ -1992,7 +2031,7 @@ pyqwikswitch==0.93 pyrail==0.0.3 # homeassistant.components.rainbird -pyrainbird==4.0.0 +pyrainbird==4.0.1 # homeassistant.components.recswitch pyrecswitch==1.0.2 @@ -2001,7 +2040,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 @@ -2019,7 +2058,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.11.0 +pyschlage==2023.12.1 # homeassistant.components.sensibo pysensibo==1.0.36 @@ -2066,7 +2105,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.snmp -pysnmplib==5.0.21 +pysnmp-lextudio==5.0.31 # homeassistant.components.snooz pysnooz==0.8.6 @@ -2078,13 +2117,13 @@ pysoma==0.0.12 pyspcwebgw==0.7.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.3 +pysqueezebox==0.7.1 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuez==0.1.19 +pysuez==0.2.0 # homeassistant.components.switchbee pyswitchbee==1.8.0 @@ -2095,12 +2134,18 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.tedee +pytedee-async==0.2.6 + # homeassistant.components.tfiac pytfiac==0.4 # homeassistant.components.thinkingcleaner pythinkingcleaner==0.0.3 +# homeassistant.components.motionmount +python-MotionMount==0.3.1 + # homeassistant.components.awair python-awair==0.2.4 @@ -2119,9 +2164,6 @@ python-digitalocean==1.13.2 # homeassistant.components.ecobee python-ecobee-api==0.2.17 -# homeassistant.components.eq3btsmart -# python-eq3bt==0.2 - # homeassistant.components.etherscan python-etherscan-api==0.0.3 @@ -2141,7 +2183,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==3.1.0 +python-homewizard-energy==4.1.0 # homeassistant.components.hp_ilo python-hpilo==4.3 @@ -2162,7 +2204,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==4.0.0 +python-matter-server==5.1.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2174,10 +2216,10 @@ python-mpd2==3.0.5 python-mystrom==2.2.0 # homeassistant.components.swiss_public_transport -python-opendata-transport==0.3.0 +python-opendata-transport==0.4.0 # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread @@ -2193,16 +2235,16 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.36.1 +python-roborock==0.38.0 # homeassistant.components.smarttub -python-smarttub==0.0.35 +python-smarttub==0.0.36 # homeassistant.components.songpal python-songpal==0.16 # homeassistant.components.tado -python-tado==0.15.0 +python-tado==0.17.3 # homeassistant.components.telegram_bot python-telegram-bot==13.1 @@ -2232,7 +2274,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.8 +pytrafikverket==0.3.9.2 # homeassistant.components.v2c pytrydan==0.4.0 @@ -2241,7 +2283,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.21.0 +pyunifiprotect==4.22.5 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2262,7 +2304,7 @@ pyvesync==2.1.10 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.20 +pyvlx==0.2.21 # homeassistant.components.volumio pyvolumio==0.1.5 @@ -2301,7 +2343,7 @@ pyzbar==0.1.7 pyzerproc==0.4.8 # homeassistant.components.qingping -qingping-ble==0.8.2 +qingping-ble==0.9.0 # homeassistant.components.qnap qnapstats==0.4.0 @@ -2324,17 +2366,20 @@ rapt-ble==0.1.2 # homeassistant.components.raspyrfm raspyrfm-client==1.2.8 +# homeassistant.components.refoss +refoss-ha==1.2.0 + # homeassistant.components.rainmachine regenmaschine==2023.06.0 # homeassistant.components.renault -renault-api==0.2.0 +renault-api==0.2.1 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.7.15 +reolink-aio==0.8.5 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2343,7 +2388,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell==0.7.3 +ring-doorbell[listen]==0.8.5 # homeassistant.components.fleetgo ritassist==0.9.2 @@ -2358,10 +2403,10 @@ rocketchat-API==0.6.1 rokuecp==0.18.1 # homeassistant.components.roomba -roombapy==1.6.8 +roombapy==1.6.10 # homeassistant.components.roon -roonapi==0.1.5 +roonapi==0.1.6 # homeassistant.components.rova rova==0.3.0 @@ -2397,7 +2442,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2419,13 +2464,13 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.5.5 +sensorpush-ble==1.6.1 # homeassistant.components.sentry -sentry-sdk==1.34.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 @@ -2464,7 +2509,7 @@ smhi-pkg==1.0.16 snapcast==2.3.3 # homeassistant.components.sonos -soco==0.29.1 +soco==0.30.0 # homeassistant.components.solaredge_local solaredge-local==0.2.3 @@ -2493,6 +2538,9 @@ spiderpy==1.6.1 # homeassistant.components.spotify spotipy==2.23.0 +# homeassistant.components.sql +sqlparse==0.4.4 + # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -2532,20 +2580,23 @@ subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.sunweg +sunweg==2.0.3 + # homeassistant.components.surepetcare -surepy==0.8.0 +surepy==0.9.0 # homeassistant.components.swiss_hydrological_data swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.2.1 +switchbot-api==1.3.0 # homeassistant.components.synology_srm synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.9.5 +systembridgeconnector==3.10.0 # homeassistant.components.tailscale tailscale==0.6.0 @@ -2569,7 +2620,7 @@ tellduslive==0.10.11 temescal==0.5 # homeassistant.components.temper -temperusb==1.6.0 +temperusb==1.6.1 # homeassistant.components.tensorflow # tensorflow==2.5.0 @@ -2580,14 +2631,17 @@ tesla-powerwall==0.3.19 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 +# homeassistant.components.tessie +tessie-api==0.0.9 + # homeassistant.components.tensorflow # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.6.0 +thermobeacon-ble==0.6.2 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.thermoworks_smoke thermoworks-smoke==0.1.8 @@ -2632,7 +2686,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 @@ -2649,11 +2703,14 @@ ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 +# homeassistant.components.unifi_direct +unifi_ap==0.0.1 + # homeassistant.components.unifiled 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 @@ -2669,14 +2726,17 @@ 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==4.0.2 # homeassistant.components.rdw -vehicle==2.2.0 +vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2023.10.2 +velbus-aio==2023.11.0 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2743,7 +2803,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.4.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2773,19 +2833,19 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.2 +yalexs-ble==2.4.0 # homeassistant.components.august yalexs==1.10.0 # homeassistant.components.yeelight -yeelight==0.7.13 +yeelight==0.7.14 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.3.1 +yolink-api==0.3.4 # homeassistant.components.youless youless-api==1.0.1 @@ -2794,22 +2854,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.123.0 +zeroconf==0.131.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.106 +zha-quirks==0.0.109 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2818,25 +2878,25 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.21.1 +zigpy-deconz==0.22.4 # homeassistant.components.zha -zigpy-xbee==0.19.0 +zigpy-xbee==0.20.1 # 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.1 # homeassistant.components.zha -zigpy==0.59.0 +zigpy==0.60.4 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.53.1 +zwave-js-server-python==0.55.2 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index bc88a59fc8ecf4..c814a035d2d6e2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,13 +8,13 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.0.1 -coverage==7.3.2 -freezegun==1.2.2 +coverage==7.3.4 +freezegun==1.3.1 mock-open==1.4.0 -mypy==1.7.0 -pre-commit==3.5.0 +mypy==1.8.0 +pre-commit==3.6.0 pydantic==1.10.12 -pylint==3.0.2 +pylint==3.0.3 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 @@ -28,7 +28,7 @@ pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.5.0 pytest-xdist==3.3.1 -pytest==7.4.3 +pytest==7.4.4 requests-mock==1.11.0 respx==0.20.2 syrupy==4.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f244425a17ebd..888906f875fd2d 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.6 +AEMET-OpenData==0.4.7 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.17 +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 @@ -28,7 +25,7 @@ DoorBirdPy==2.1.0 HAP-python==4.9.1 # homeassistant.components.tasmota -HATasmota==0.7.3 +HATasmota==0.8.0 # homeassistant.components.doods # homeassistant.components.generic @@ -42,13 +39,13 @@ HATasmota==0.7.3 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.7 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==13.0.7 +PyChromecast==13.0.8 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -87,7 +84,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.1 +PySwitchbot==0.43.0 # homeassistant.components.syncthru PySyncThru==0.7.10 @@ -100,26 +97,23 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.28.1 +PyViCare==2.32.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 # homeassistant.components.rachio -RachioPy==1.0.3 - -# homeassistant.components.python_script -RestrictedPython==6.2;python_version<'3.12' +RachioPy==1.1.0 # 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.23 +SQLAlchemy==2.0.25 # 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,17 +164,20 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.2.4 +aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.6 +aioairzone-cloud==0.3.8 # homeassistant.components.airzone -aioairzone==0.6.9 +aioairzone==0.7.2 # homeassistant.components.ambient_station aioambient==2023.04.0 +# homeassistant.components.apcupsd +aioapcaccess==0.4.2 + # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -197,10 +194,10 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.3.0 +aiocomelit==0.7.0 # homeassistant.components.dhcp -aiodiscover==1.5.1 +aiodiscover==1.6.0 # homeassistant.components.dnsip aiodns==3.0.0 @@ -214,11 +211,14 @@ aioeagle==1.1.0 # homeassistant.components.ecowitt aioecowitt==2023.5.0 +# homeassistant.components.co2signal +aioelectricitymaps==0.1.6 + # homeassistant.components.emonitor aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.4.0 +aioesphomeapi==21.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -233,13 +233,13 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.9 +aiohomekit==3.1.2 # homeassistant.components.http -aiohttp-fast-url-dispatcher==0.1.0 +aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.1 +aiohttp-zlib-ng==0.1.3 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -258,10 +258,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 @@ -294,7 +294,7 @@ aioopenexchangerates==0.4.0 aiopegelonline==0.0.6 # homeassistant.components.acmeda -aiopulse==0.4.3 +aiopulse==0.4.4 # homeassistant.components.purpleair aiopurpleair==2022.12.1 @@ -329,7 +329,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.0.0 +aioshelly==7.0.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -350,16 +350,16 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==65 +aiounifi==68 # 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 @@ -368,7 +368,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 @@ -400,11 +400,8 @@ anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 -# homeassistant.components.apcupsd -apcaccess==0.0.13 - # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.2 # homeassistant.components.apprise apprise==1.6.0 @@ -418,19 +415,16 @@ 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 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 # homeassistant.components.sleepiq -asyncsleepiq==1.3.7 +asyncsleepiq==1.4.1 # homeassistant.components.aurora auroranoaa==0.0.3 @@ -444,6 +438,9 @@ axis==48 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.homekit base36==0.1.1 @@ -451,13 +448,16 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.8 +bellows==0.37.6 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.2 +bimmer-connected[china]==0.14.6 + +# homeassistant.components.esphome +bleak-esphome==0.4.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 # homeassistant.components.bluetooth bleak==0.21.1 @@ -466,50 +466,55 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.3 +blinkpy==0.22.4 + +# homeassistant.components.blue_current +bluecurrent-api==1.0.6 # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.1 +bluetooth-adapters==0.17.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth -# homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.14.0 +bluetooth-data-tools==1.19.0 # homeassistant.components.bond bond-async==0.2.1 # homeassistant.components.bosch_shc -boschshcpy==0.2.57 +boschshcpy==0.2.75 # homeassistant.components.broadlink broadlink==0.18.3 # homeassistant.components.brother -brother==2.3.0 +brother==3.0.0 # homeassistant.components.brottsplatskartan -brottsplatskartan==0.0.1 +brottsplatskartan==1.0.5 # homeassistant.components.brunt brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.2.0 +bthome-ble==3.3.1 # homeassistant.components.buienradar buienradar==1.0.5 +# homeassistant.components.dhcp +cached_ipaddress==0.3.0 + # homeassistant.components.caldav -caldav==1.3.6 +caldav==1.3.8 # homeassistant.components.coinbase coinbase==2.1.0 @@ -520,7 +525,6 @@ colorlog==6.7.0 # homeassistant.components.color_extractor colorthief==0.2.1 -# homeassistant.components.eq3btsmart # homeassistant.components.xiaomi_miio construct==2.10.68 @@ -543,7 +547,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.21.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -562,8 +566,11 @@ demetriek==0.4.0 # homeassistant.components.denonavr denonavr==0.11.4 +# homeassistant.components.devialet +devialet==1.4.5 + # homeassistant.components.devolo_home_control -devolo-home-control-api==0.18.2 +devolo-home-control-api==0.18.3 # homeassistant.components.devolo_home_network devolo-plc-api==1.4.1 @@ -577,6 +584,9 @@ discovery30303==0.2.1 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 +# homeassistant.components.drop_connect +dropmqttapi==1.0.2 + # homeassistant.components.dsmr dsmr-parser==1.3.1 @@ -593,13 +603,13 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.3.0 +easyenergy==2.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.1.0 +elgato==5.1.1 # homeassistant.components.elkm1 elkm1-lib==2.2.6 @@ -614,7 +624,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.5.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 @@ -631,18 +641,27 @@ epson-projector==0.5.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 +# homeassistant.components.netgear_lte +eternalegypt==0.0.16 + # homeassistant.components.eufylife_ble 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 +feedparser==6.0.11 # homeassistant.components.file file-read-backwards==2.0.0 +# homeassistant.components.fints +fints==3.1.0 + # homeassistant.components.fitbit fitbit==0.3.1 @@ -652,6 +671,9 @@ fivem-api==0.1.2 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 @@ -679,13 +701,13 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.0 +gardena-bluetooth==1.4.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==5.0.0 +gcal-sync==6.0.3 # homeassistant.components.geocaching geocachingapi==0.2.1 @@ -707,13 +729,13 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.8.2 +getmac==0.9.4 # homeassistant.components.gios -gios==3.2.1 +gios==3.2.2 # homeassistant.components.glances -glances-api==0.4.3 +glances-api==0.5.0 # homeassistant.components.goalzero goalzero==0.2.2 @@ -729,7 +751,7 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.13.11 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.1.0 +google-generativeai==0.3.1 # homeassistant.components.nest google-nest-sdm==3.0.3 @@ -737,6 +759,9 @@ google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 +# homeassistant.components.tailwind +gotailwind==0.2.2 + # homeassistant.components.govee_ble govee-ble==0.24.0 @@ -747,7 +772,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 @@ -777,11 +802,14 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==2.0.2 + # homeassistant.components.cloud -hass-nabucasa==0.74.0 +hass-nabucasa==0.75.1 # homeassistant.components.conversation -hassil==1.2.5 +hassil==1.5.2 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -798,14 +826,15 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday -holidays==0.35 +holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20231030.2 +home-assistant-frontend==20240104.0 # homeassistant.components.conversation -home-assistant-intents==2023.10.16 +home-assistant-intents==2024.1.2 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -820,7 +849,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,13 +862,13 @@ ibeacon-ble==1.0.1 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.1.0 +ical==6.1.1 # homeassistant.components.ping icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.3 +idasen-ha==2.4 # homeassistant.components.network ifaddr==0.2.0 @@ -886,9 +915,6 @@ kegtron-ble==0.4.0 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 @@ -917,7 +943,10 @@ librouteros==3.2.0 libsoundtouch==0.8 # homeassistant.components.life360 -life360==6.0.0 +life360==6.0.1 + +# homeassistant.components.linear_garage_door +linear-garage-door==0.2.7 # homeassistant.components.logi_circle logi-circle==0.2.3 @@ -932,7 +961,7 @@ loqedAPI==2.1.8 luftdaten==0.7.4 # homeassistant.components.scrape -lxml==4.9.3 +lxml==4.9.4 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 @@ -959,7 +988,7 @@ medcom-ble==0.1.1 melnor-bluetooth==0.0.25 # homeassistant.components.meteo_france -meteofrance-api==1.2.0 +meteofrance-api==1.3.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -971,7 +1000,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.8 # homeassistant.components.minio minio==7.1.12 @@ -983,10 +1012,10 @@ 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 +motionblinds==0.6.19 # homeassistant.components.motioneye motioneye-client==0.3.14 @@ -1000,6 +1029,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 @@ -1010,7 +1042,7 @@ nessclient==1.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.2.1 +nettigo-air-monitor==2.2.2 # homeassistant.components.nexia nexia==2.0.7 @@ -1022,10 +1054,10 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==2.0.1 +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 @@ -1057,7 +1089,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 @@ -1075,7 +1107,7 @@ open-garage==0.2.0 open-meteo==0.3.1 # homeassistant.components.openai_conversation -openai==0.27.2 +openai==1.3.8 # homeassistant.components.openerz openerz-api==0.2.0 @@ -1084,16 +1116,19 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.39 +opower==0.1.0 # 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 @@ -1110,12 +1145,6 @@ peco==0.0.29 # homeassistant.components.escea pescea==1.0.12 -# homeassistant.components.aruba -# homeassistant.components.cisco_ios -# homeassistant.components.pandora -# homeassistant.components.unifi_direct -pexpect==4.6.0 - # homeassistant.components.modem_callerid phone-modem==0.1.1 @@ -1129,7 +1158,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.2 +plugwise==0.35.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1141,7 +1170,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 @@ -1150,6 +1179,9 @@ prometheus-client==0.17.1 # homeassistant.components.recorder psutil-home-assistant==0.0.1 +# homeassistant.components.systemmonitor +psutil==5.9.7 + # homeassistant.components.androidtv pure-python-adb[async]==0.3.0.dev0 @@ -1160,11 +1192,17 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.1.0 +pvo==2.1.1 + +# homeassistant.components.aosmith +py-aosmith==1.0.1 # homeassistant.components.canary py-canary==0.5.3 +# homeassistant.components.ccm15 +py-ccm15==0.0.9 + # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 @@ -1196,7 +1234,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.11.1 +pyDuotecno==2024.1.1 # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1223,11 +1261,14 @@ pyairnow==1.2.1 # homeassistant.components.airvisual_pro pyairvisual==2023.08.1 +# homeassistant.components.asuswrt +pyasuswrt==0.1.21 + # homeassistant.components.atag pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.6.0 +pyatmo==8.0.2 # homeassistant.components.apple_tv pyatv==0.14.3 @@ -1263,7 +1304,7 @@ pycsspeechtts==1.0.8 pydaikin==2.11.1 # homeassistant.components.deconz -pydeconz==113 +pydeconz==114 # homeassistant.components.dexcom pydexcom==0.2.3 @@ -1272,7 +1313,7 @@ pydexcom==0.2.3 pydiscovergy==2.0.5 # homeassistant.components.hydrawise -pydrawise==2023.11.0 +pydrawise==2024.1.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1287,7 +1328,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.2 +pyenphase==1.15.2 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1332,7 +1373,7 @@ pyhaversion==22.8.0 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.14 +pyhiveapi==0.5.16 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -1344,7 +1385,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.1 +pyinsteon==1.5.3 # homeassistant.components.ipma pyipma==3.0.7 @@ -1377,7 +1418,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 @@ -1395,7 +1436,7 @@ pylaunches==1.4.0 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.5.0 +pylitejet==0.6.2 # homeassistant.components.litterrobot pylitterbot==2023.4.9 @@ -1440,7 +1481,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,6 +1501,9 @@ pyopenuv==2023.02.0 # homeassistant.components.opnsense pyopnsense==0.4.0 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 @@ -1469,7 +1513,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.0 +pyoverkiz==1.13.3 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1496,7 +1540,7 @@ pyprof2calltree==1.4.5 pyprosegur==0.0.9 # homeassistant.components.prusalink -pyprusalink==1.1.0 +pyprusalink==2.0.0 # homeassistant.components.ps4 pyps4-2ndscreen==1.3.1 @@ -1505,10 +1549,10 @@ pyps4-2ndscreen==1.3.1 pyqwikswitch==0.93 # homeassistant.components.rainbird -pyrainbird==4.0.0 +pyrainbird==4.0.1 # homeassistant.components.risco -pyrisco==0.5.7 +pyrisco==0.5.8 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -1523,7 +1567,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.11.0 +pyschlage==2023.12.1 # homeassistant.components.sensibo pysensibo==1.0.36 @@ -1564,7 +1608,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.snmp -pysnmplib==5.0.21 +pysnmp-lextudio==5.0.31 # homeassistant.components.snooz pysnooz==0.8.6 @@ -1576,7 +1620,10 @@ pysoma==0.0.12 pyspcwebgw==0.7.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.3 +pysqueezebox==0.7.1 + +# homeassistant.components.suez_water +pysuez==0.2.0 # homeassistant.components.switchbee pyswitchbee==1.8.0 @@ -1587,6 +1634,12 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.tedee +pytedee-async==0.2.6 + +# homeassistant.components.motionmount +python-MotionMount==0.3.1 + # homeassistant.components.awair python-awair==0.2.4 @@ -1600,7 +1653,7 @@ python-ecobee-api==0.2.17 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==3.1.0 +python-homewizard-energy==4.1.0 # homeassistant.components.izone python-izone==1.2.9 @@ -1612,7 +1665,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==4.0.0 +python-matter-server==5.1.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -1620,8 +1673,11 @@ python-miio==0.5.12 # homeassistant.components.mystrom python-mystrom==2.2.0 +# homeassistant.components.swiss_public_transport +python-opendata-transport==0.4.0 + # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread @@ -1634,16 +1690,16 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.36.1 +python-roborock==0.38.0 # homeassistant.components.smarttub -python-smarttub==0.0.35 +python-smarttub==0.0.36 # homeassistant.components.songpal python-songpal==0.16 # homeassistant.components.tado -python-tado==0.15.0 +python-tado==0.17.3 # homeassistant.components.telegram_bot python-telegram-bot==13.1 @@ -1664,7 +1720,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.8 +pytrafikverket==0.3.9.2 # homeassistant.components.v2c pytrydan==0.4.0 @@ -1673,7 +1729,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.21.0 +pyunifiprotect==4.22.5 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1718,7 +1774,7 @@ pyyardian==1.1.1 pyzerproc==0.4.8 # homeassistant.components.qingping -qingping-ble==0.8.2 +qingping-ble==0.9.0 # homeassistant.components.qnap qnapstats==0.4.0 @@ -1732,32 +1788,35 @@ radiotherm==2.1.0 # homeassistant.components.rapt_ble rapt-ble==0.1.2 +# homeassistant.components.refoss +refoss-ha==1.2.0 + # homeassistant.components.rainmachine regenmaschine==2023.06.0 # homeassistant.components.renault -renault-api==0.2.0 +renault-api==0.2.1 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.7.15 +reolink-aio==0.8.5 # homeassistant.components.rflink rflink==0.0.65 # homeassistant.components.ring -ring-doorbell==0.7.3 +ring-doorbell[listen]==0.8.5 # homeassistant.components.roku rokuecp==0.18.1 # homeassistant.components.roomba -roombapy==1.6.8 +roombapy==1.6.10 # homeassistant.components.roon -roonapi==0.1.5 +roonapi==0.1.6 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 @@ -1781,7 +1840,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.backup securetar==2023.3.0 @@ -1797,13 +1856,13 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.5.5 +sensorpush-ble==1.6.1 # homeassistant.components.sentry -sentry-sdk==1.34.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 @@ -1830,7 +1889,7 @@ smhi-pkg==1.0.16 snapcast==2.3.3 # homeassistant.components.sonos -soco==0.29.1 +soco==0.30.0 # homeassistant.components.solaredge solaredge==0.0.2 @@ -1856,6 +1915,9 @@ spiderpy==1.6.1 # homeassistant.components.spotify spotipy==2.23.0 +# homeassistant.components.sql +sqlparse==0.4.4 + # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -1877,6 +1939,9 @@ stookalert==0.1.4 # homeassistant.components.stookwijzer stookwijzer==1.3.0 +# homeassistant.components.streamlabswater +streamlabswater==1.0.1 + # homeassistant.components.huawei_lte # homeassistant.components.solaredge # homeassistant.components.thermoworks_smoke @@ -1889,14 +1954,17 @@ subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.sunweg +sunweg==2.0.3 + # homeassistant.components.surepetcare -surepy==0.8.0 +surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.2.1 +switchbot-api==1.3.0 # homeassistant.components.system_bridge -systembridgeconnector==3.9.5 +systembridgeconnector==3.10.0 # homeassistant.components.tailscale tailscale==0.6.0 @@ -1908,7 +1976,7 @@ tellduslive==0.10.11 temescal==0.5 # homeassistant.components.temper -temperusb==1.6.0 +temperusb==1.6.1 # homeassistant.components.powerwall tesla-powerwall==0.3.19 @@ -1916,11 +1984,14 @@ tesla-powerwall==0.3.19 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 +# homeassistant.components.tessie +tessie-api==0.0.9 + # homeassistant.components.thermobeacon -thermobeacon-ble==0.6.0 +thermobeacon-ble==0.6.2 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 @@ -1950,7 +2021,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 @@ -1968,7 +2039,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 @@ -1984,14 +2055,17 @@ 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==4.0.2 # homeassistant.components.rdw -vehicle==2.2.0 +vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2023.10.2 +velbus-aio==2023.11.0 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2043,7 +2117,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.4.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2070,16 +2144,16 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.2 +yalexs-ble==2.4.0 # homeassistant.components.august yalexs==1.10.0 # homeassistant.components.yeelight -yeelight==0.7.13 +yeelight==0.7.14 # homeassistant.components.yolink -yolink-api==0.3.1 +yolink-api==0.3.4 # homeassistant.components.youless youless-api==1.0.1 @@ -2088,37 +2162,37 @@ 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.123.0 +zeroconf==0.131.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.106 +zha-quirks==0.0.109 # homeassistant.components.zha -zigpy-deconz==0.21.1 +zigpy-deconz==0.22.4 # homeassistant.components.zha -zigpy-xbee==0.19.0 +zigpy-xbee==0.20.1 # 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.1 # homeassistant.components.zha -zigpy==0.59.0 +zigpy==0.60.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.53.1 +zwave-js-server-python==0.55.2 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 03c46de6b37db4..a02eed66ffa50e 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.11.0 codespell==2.2.2 -ruff==0.1.1 +ruff==0.1.8 yamllint==1.32.0 diff --git a/script/check_format b/script/check_format index bed35ec63e48c6..09dbb0abe86c9d 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 cb202ed0466087..7f652b14302bce 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -32,7 +32,6 @@ "pybluez", "pycocotools", "pycups", - "python-eq3bt", "python-gammu", "python-lirc", "pyuserinput", @@ -60,11 +59,6 @@ # see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 -# Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 -# Temporary setting an upper bound, to prevent compat issues with urllib3>=2 -# https://github.com/home-assistant/core/issues/97248 -urllib3>=1.26.5,<2 - # Constrain httplib2 to protect against GHSA-93xj-8mrv-444m # https://github.com/advisories/GHSA-93xj-8mrv-444m httplib2>=0.19.0 @@ -104,9 +98,9 @@ # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.0.0 +anyio==4.1.0 h11==0.14.0 -httpcore==0.18.0 +httpcore==1.0.2 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation @@ -150,7 +144,7 @@ # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.25.0 +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 @@ -180,14 +174,27 @@ # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. charset-normalizer==3.2.0 + +# lxml 5.0.0 currently does not build on alpine 3.18 +# https://bugs.launchpad.net/lxml/+bug/2047718 +lxml==4.9.4 + +# dacite: Ensure we have a version that is able to handle type unions for +# Roborock, NAM, Brother, and GIOS. +dacite>=1.7.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 +361,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 +376,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 +388,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 +397,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 +434,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 32803731ecd8e6..c454c69d14175b 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -16,6 +16,7 @@ coverage, dependencies, dhcp, + docker, json, manifest, metadata, @@ -50,6 +51,7 @@ ] HASS_PLUGINS = [ coverage, + docker, mypy_config, metadata, ] diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py new file mode 100644 index 00000000000000..c9d81424229fae --- /dev/null +++ b/script/hassfest/docker.py @@ -0,0 +1,90 @@ +"""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.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_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 499ee9d51d91e1..b56306a8d7e9dd 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 @@ def format_python( {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/services.py b/script/hassfest/services.py index 4a826f7cad93c3..580294705cf9ed 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_SELECTOR from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, selector, service -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .model import Config, Integration @@ -107,7 +107,7 @@ def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool def validate_services(config: Config, integration: Integration) -> None: """Validate services.""" try: - data = load_yaml(str(integration.path / "services.yaml")) + data = load_yaml_dict(str(integration.path / "services.yaml")) except FileNotFoundError: # Find if integration uses services has_services = grep_dir( @@ -122,7 +122,7 @@ def validate_services(config: Config, integration: Integration) -> None: ) return except HomeAssistantError: - integration.add_error("services", "Unable to load services.yaml") + integration.add_error("services", "Invalid services.yaml") return try: diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 950eeb827ba758..738ebcb260abee 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -33,6 +33,7 @@ "garages_amsterdam", "generic", "google_travel_time", + "holiday", "homekit_controller", "islamic_prayer_times", "local_calendar", @@ -215,6 +216,29 @@ def name_validator(value: dict[str, Any]) -> dict[str, Any]: 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 +290,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, @@ -362,7 +368,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 ee28d4765d61d6..48809ae4dcd1e7 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 8dafd8fa802f6a..ddbd1189e11d88 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 ff503bc12db9e4..197c36e22d1dfa 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -185,9 +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_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]", + "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/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 3dd60b51296751..caef6c2e72965b 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -18,9 +19,9 @@ # TODO adjust the data schema to the data that you need STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, - vol.Required("username"): str, - vol.Required("password"): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -50,12 +51,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, # If your PyPI package is not built with async, pass your methods # to the executor: # await hass.async_add_executor_job( - # your_validate_func, data["username"], data["password"] + # your_validate_func, data[CONF_USERNAME], data[CONF_PASSWORD] # ) - hub = PlaceholderHub(data["host"]) + hub = PlaceholderHub(data[CONF_HOST]) - if not await hub.authenticate(data["username"], data["password"]): + if not await hub.authenticate(data[CONF_USERNAME], data[CONF_PASSWORD]): raise InvalidAuth # If you cannot connect: diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index cbc1449378caf9..bb9e6380cdcf4f 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -1,16 +1,13 @@ """Test the NEW_NAME config flow.""" from unittest.mock import AsyncMock, patch -import pytest - from homeassistant import config_entries from homeassistant.components.NEW_DOMAIN.config_flow import CannotConnect, InvalidAuth from homeassistant.components.NEW_DOMAIN.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -18,33 +15,35 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["errors"] is None + assert result["errors"] == {} with patch( "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Name of the device" - assert result2["data"] == { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", } assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -54,20 +53,48 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=InvalidAuth, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -77,14 +104,41 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=CannotConnect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/auth/providers/test_command_line.py b/tests/auth/providers/test_command_line.py index 97f8f659397658..a92d41a8c5f413 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 0e689e338f1dc1..4cbd7946a29a26 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 7c2335f7ccca9a..3d89c577ebf14d 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.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/auth/test_init.py b/tests/auth/test_init.py index ef7beab488b725..9e9b48a07f6048 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -894,10 +894,7 @@ async def test_auth_module_expired_session(mock_hass) -> None: assert step["type"] == data_entry_flow.FlowResultType.FORM assert step["step_id"] == "mfa" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() + MFA_SESSION_EXPIRATION, - ): + with freeze_time(dt_util.utcnow() + MFA_SESSION_EXPIRATION): step = await manager.login_flow.async_configure( step["flow_id"], {"pin": "test-pin"} ) diff --git a/tests/common.py b/tests/common.py index 1737eae21e63a0..b07788dc3d73a3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -6,6 +6,7 @@ from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager from datetime import UTC, datetime, timedelta +from enum import Enum import functools as ft from functools import lru_cache from io import StringIO @@ -15,10 +16,12 @@ import pathlib import threading import time +from types import ModuleType from typing import Any, NoReturn from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 +import pytest import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader @@ -88,6 +91,10 @@ import homeassistant.util.uuid as uuid_util import homeassistant.util.yaml.loader as yaml_loader +from tests.testing_config.custom_components.test_constant_deprecation import ( + import_deprecated_costant, +) + _LOGGER = logging.getLogger(__name__) INSTANCES = [] CLIENT_ID = "https://example.com/app" @@ -267,7 +274,7 @@ def async_create_task(coroutine, name=None): "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 +304,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,6 +313,8 @@ 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: @@ -887,6 +897,7 @@ def __init__( domain="test", data=None, version=1, + minor_version=1, entry_id=None, source=config_entries.SOURCE_USER, title="Mock Title", @@ -907,6 +918,7 @@ def __init__( "pref_disable_polling": pref_disable_polling, "options": options, "version": version, + "minor_version": minor_version, "title": title, "unique_id": unique_id, "disabled_by": disabled_by, @@ -984,7 +996,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", @@ -992,7 +1007,7 @@ async def mock_psc(hass, config_input, integration): 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): @@ -1210,7 +1225,7 @@ def _handle(self, attr: str) -> Any: @contextmanager def mock_storage( - data: dict[str, Any] | None = None + data: dict[str, Any] | None = None, ) -> Generator[dict[str, Any], None, None]: """Mock storage. @@ -1301,11 +1316,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( @@ -1339,18 +1355,6 @@ def mock_import_platform(platform_name: str) -> NoReturn: 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: @@ -1432,7 +1436,7 @@ def __repr__(self) -> str: def raise_contains_mocks(val: Any) -> None: """Raise for mocks.""" if isinstance(val, Mock): - raise TypeError + raise TypeError(val) if isinstance(val, dict): for dict_value in val.values(): @@ -1463,3 +1467,59 @@ def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> else: state = CloudConnectionState.CLOUD_DISCONNECTED async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) + + +def import_and_test_deprecated_constant_enum( + caplog: pytest.LogCaptureFixture, + module: ModuleType, + replacement: Enum, + constant_prefix: str, + breaks_in_ha_version: str, +) -> None: + """Import and test deprecated constant replaced by a enum. + + - Import deprecated enum + - Assert value is the same as the replacement + - Assert a warning is logged + - Assert the deprecated constant is included in the modules.__dir__() + """ + import_and_test_deprecated_constant( + caplog, + module, + constant_prefix + replacement.name, + f"{replacement.__class__.__name__}.{replacement.name}", + replacement, + breaks_in_ha_version, + ) + + +def import_and_test_deprecated_constant( + caplog: pytest.LogCaptureFixture, + module: ModuleType, + constant_name: str, + replacement_name: str, + replacement: Any, + breaks_in_ha_version: str, +) -> None: + """Import and test deprecated constant replaced by a value. + + - Import deprecated constant + - Assert value is the same as the replacement + - Assert a warning is logged + - Assert the deprecated constant is included in the modules.__dir__() + """ + value = import_deprecated_costant(module, constant_name) + assert value == replacement + assert ( + module.__name__, + logging.WARNING, + ( + f"{constant_name} was used from test_constant_deprecation," + f" this is a deprecated constant which will be removed in HA Core {breaks_in_ha_version}. " + f"Use {replacement_name} instead, please report " + "it to the author of the 'test_constant_deprecation' custom integration" + ), + ) in caplog.record_tuples + + # verify deprecated constant is included in dir() + assert constant_name in dir(module) diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr index 521393af71b339..081e7bf595a9a4 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_weather.py b/tests/components/accuweather/test_weather.py index 5a35f2798d8930..920e5cf82b9e03 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -3,6 +3,7 @@ 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 @@ 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 ( @@ -206,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", @@ -223,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/__init__.py b/tests/components/advantage_air/__init__.py index b826e3ac7ced61..05d98e957bb0f4 100644 --- a/tests/components/advantage_air/__init__.py +++ b/tests/components/advantage_air/__init__.py @@ -1,12 +1,14 @@ """Tests for the Advantage Air component.""" +from unittest.mock import AsyncMock, patch + from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture -TEST_SYSTEM_DATA = load_fixture("advantage_air/getSystemData.json") -TEST_SET_RESPONSE = load_fixture("advantage_air/setAircon.json") +TEST_SYSTEM_DATA = load_json_object_fixture("getSystemData.json", DOMAIN) +TEST_SET_RESPONSE = None USER_INPUT = { CONF_IP_ADDRESS: "1.2.3.4", @@ -25,6 +27,22 @@ ) +def patch_get(return_value=TEST_SYSTEM_DATA, side_effect=None): + """Patch the Advantage Air async_get method.""" + return patch( + "homeassistant.components.advantage_air.advantage_air.async_get", + new=AsyncMock(return_value=return_value, side_effect=side_effect), + ) + + +def patch_update(return_value=True, side_effect=None): + """Patch the Advantage Air async_set method.""" + return patch( + "homeassistant.components.advantage_air.advantage_air._endpoint.async_update", + new=AsyncMock(return_value=return_value, side_effect=side_effect), + ) + + async def add_mock_config(hass): """Create a fake Advantage Air Config Entry.""" entry = MockConfigEntry( @@ -33,6 +51,7 @@ async def add_mock_config(hass): unique_id="0123456", data=USER_INPUT, ) + entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/advantage_air/conftest.py b/tests/components/advantage_air/conftest.py new file mode 100644 index 00000000000000..9da0a176309dcd --- /dev/null +++ b/tests/components/advantage_air/conftest.py @@ -0,0 +1,20 @@ +"""Fixtures for advantage_air.""" +from __future__ import annotations + +import pytest + +from . import patch_get, patch_update + + +@pytest.fixture +def mock_get(): + """Fixture to patch the Advantage Air async_get method.""" + with patch_get() as mock_get: + yield mock_get + + +@pytest.fixture +def mock_update(): + """Fixture to patch the Advantage Air async_get method.""" + with patch_update() as mock_get: + yield mock_get diff --git a/tests/components/advantage_air/snapshots/test_climate.ambr b/tests/components/advantage_air/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..9e21d0ede177a5 --- /dev/null +++ b/tests/components/advantage_air/snapshots/test_climate.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_climate_myauto_main[climate.myauto-fanmode] + dict({ + 'ac3': dict({ + 'info': dict({ + 'fan': 'autoAA', + }), + }), + }) +# --- +# name: test_climate_myauto_main[climate.myauto-settemp] + dict({ + 'ac3': dict({ + 'info': dict({ + 'myAutoCoolTargetTemp': 23.0, + 'myAutoHeatTargetTemp': 21.0, + }), + }), + }) +# --- +# name: test_climate_myauto_main[climate.myauto] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'low', + 'medium', + 'high', + 'auto', + ]), + 'friendly_name': 'myauto', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 32, + 'min_temp': 16, + 'supported_features': , + 'target_temp_high': 24, + 'target_temp_low': 20, + 'target_temp_step': 1, + 'temperature': 24, + }), + 'context': , + 'entity_id': 'climate.myauto', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/advantage_air/snapshots/test_switch.ambr b/tests/components/advantage_air/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..2060c0798ed3ad --- /dev/null +++ b/tests/components/advantage_air/snapshots/test_switch.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_cover_async_setup_entry[switch.myzone_myfan-turnoff] + dict({ + 'ac1': dict({ + 'info': dict({ + 'aaAutoFanModeEnabled': False, + }), + }), + }) +# --- +# name: test_cover_async_setup_entry[switch.myzone_myfan-turnon] + dict({ + 'ac1': dict({ + 'info': dict({ + 'aaAutoFanModeEnabled': True, + }), + }), + }) +# --- +# name: test_cover_async_setup_entry[switch.myzone_myfan] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'myzone MyFan', + 'icon': 'mdi:fan-auto', + }), + 'context': , + 'entity_id': 'switch.myzone_myfan', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index c6d055f396ab9f..19b0dba2eda101 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the Advantage Air Binary Sensor Platform.""" from datetime import timedelta +from unittest.mock import AsyncMock from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON @@ -7,37 +8,20 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import add_mock_config from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker async def test_binary_sensor_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, ) -> None: """Test binary sensor setup.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test First Air Filter entity_id = "binary_sensor.myzone_filter" state = hass.states.get(entity_id) @@ -83,6 +67,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -91,6 +76,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state @@ -105,6 +91,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -113,6 +100,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index a1eb886cbd019e..704e25c0473014 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -1,20 +1,11 @@ """Test the Advantage Air Climate Platform.""" -from json import loads +from unittest.mock import AsyncMock + +from advantage_air import ApiError import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.advantage_air.climate import ( - ADVANTAGE_AIR_COOL_TARGET, - ADVANTAGE_AIR_HEAT_TARGET, - HASS_FAN_MODES, - HASS_HVAC_MODES, -) -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, - ADVANTAGE_AIR_STATE_OPEN, -) from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, @@ -24,6 +15,7 @@ ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, FAN_LOW, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, @@ -37,35 +29,20 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config -async def test_climate_async_setup_entry( +async def test_climate_myzone_main( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: - """Test climate platform.""" + """Test climate platform main entity.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - # Test MyZone Climate Entity + # Test MyZone main climate entity entity_id = "climate.myzone" state = hass.states.get(entity_id) assert state @@ -80,19 +57,24 @@ async def test_climate_async_setup_entry( assert entry.unique_id == "uniqueid-ac1" # Test setting HVAC Mode + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + mock_update.assert_called_once() + mock_update.reset_mock() + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON - assert data["ac1"]["info"]["mode"] == HASS_HVAC_MODES[HVACMode.FAN_ONLY] - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning Off with HVAC Mode await hass.services.async_call( @@ -101,26 +83,17 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" - - # Test changing Fan Mode + mock_update.assert_called_once() + mock_update.reset_mock() + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_FAN_MODE: FAN_LOW}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["fan"] == HASS_FAN_MODES[FAN_LOW] - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test changing Temperature await hass.services.async_call( @@ -129,12 +102,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["setTemp"] == 25 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning On await hass.services.async_call( @@ -143,12 +112,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning Off await hass.services.async_call( @@ -157,12 +122,19 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() + + +async def test_climate_myzone_zone( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, +) -> None: + """Test climate platform myzone zone entity.""" + + await add_mock_config(hass) # Test Climate Zone Entity entity_id = "climate.myzone_zone_open_with_sensor" @@ -184,14 +156,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Climate Zone Off await hass.services.async_call( @@ -200,13 +166,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( CLIMATE_DOMAIN, @@ -214,18 +175,24 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) + mock_update.assert_called_once() + mock_update.reset_mock() - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + +async def test_climate_myauto_main( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test climate platform zone entity.""" + + await add_mock_config(hass) # Test MyAuto Climate Entity entity_id = "climate.myauto" - state = hass.states.get(entity_id) - assert state - assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20 - assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 24 + assert hass.states.get(entity_id) == snapshot(name=entity_id) entry = entity_registry.async_get(entity_id) assert entry @@ -241,34 +208,35 @@ async def test_climate_async_setup_entry( }, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["info"][ADVANTAGE_AIR_HEAT_TARGET] == 21 - assert data["ac3"]["info"][ADVANTAGE_AIR_COOL_TARGET] == 23 + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-settemp") + mock_update.reset_mock() + + # Test AutoFanMode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_FAN_MODE: FAN_AUTO}, + blocking=True, + ) + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-fanmode") async def test_climate_async_failed_update( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test climate change failure.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - exc=SyntaxError, - ) - await add_mock_config(hass) - with pytest.raises(HomeAssistantError): + mock_update.side_effect = ApiError + await add_mock_config(hass) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ["climate.myzone"], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/setAircon" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_config_flow.py b/tests/components/advantage_air/test_config_flow.py index fc74df5538b066..64d445a0b2026e 100644 --- a/tests/components/advantage_air/test_config_flow.py +++ b/tests/components/advantage_air/test_config_flow.py @@ -1,23 +1,18 @@ """Test the Advantage Air config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from advantage_air import ApiError from homeassistant import config_entries, data_entry_flow from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, USER_INPUT - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import TEST_SYSTEM_DATA, USER_INPUT -async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_form(hass: HomeAssistant) -> None: """Test that form shows up.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -26,6 +21,9 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> assert result1["errors"] == {} with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(return_value=TEST_SYSTEM_DATA), + ) as mock_get, patch( "homeassistant.components.advantage_air.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -34,43 +32,44 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> USER_INPUT, ) await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + mock_get.assert_called_once() - assert len(aioclient_mock.mock_calls) == 1 assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "testname" assert result2["data"] == USER_INPUT - assert len(mock_setup_entry.mock_calls) == 1 # Test Duplicate Config Flow result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - USER_INPUT, - ) + with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(return_value=TEST_SYSTEM_DATA), + ) as mock_get: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + USER_INPUT, + ) assert result4["type"] == data_entry_flow.FlowResultType.ABORT -async def test_form_cannot_connect( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - exc=SyntaxError, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) + with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(side_effect=ApiError), + ) as mock_get: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + mock_get.assert_called_once() assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "cannot_connect"} - assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/advantage_air/test_cover.py b/tests/components/advantage_air/test_cover.py index af516d16e6e35c..8166b5da941490 100644 --- a/tests/components/advantage_air/test_cover.py +++ b/tests/components/advantage_air/test_cover.py @@ -1,10 +1,6 @@ """Test the Advantage Air Cover Platform.""" -from json import loads +from unittest.mock import AsyncMock -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OPEN, -) from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -17,34 +13,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_ac_cover( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test cover platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Cover Zone Entity @@ -65,12 +44,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -78,13 +53,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac3"]["zones"]["z01"]["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -92,12 +62,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 50}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["value"] == 50 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -105,12 +71,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 0}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test controlling multiple Cover Zone Entity await hass.services.async_call( @@ -124,9 +86,9 @@ async def test_ac_cover( }, blocking=True, ) - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert len(mock_update.mock_calls) == 2 + mock_update.reset_mock() + await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, @@ -138,27 +100,18 @@ async def test_ac_cover( }, blocking=True, ) - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_OPEN + + assert len(mock_update.mock_calls) == 2 async def test_things_cover( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test cover platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Blind 1 Entity @@ -171,7 +124,7 @@ async def test_things_cover( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-200" + assert entry.unique_id == f"uniqueid-{thing_id}" await hass.services.async_call( COVER_DOMAIN, @@ -179,13 +132,8 @@ async def test_things_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -193,10 +141,4 @@ async def test_things_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_diagnostics.py b/tests/components/advantage_air/test_diagnostics.py index 01f6d809a49d71..80de90197152e7 100644 --- a/tests/components/advantage_air/test_diagnostics.py +++ b/tests/components/advantage_air/test_diagnostics.py @@ -1,28 +1,24 @@ """Test the Advantage Air Diagnostics.""" +from unittest.mock import AsyncMock + from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config +from . import add_mock_config 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_select_async_setup_entry( hass: HomeAssistant, hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, snapshot: SnapshotAssertion, + mock_get: AsyncMock, ) -> None: """Test select platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - entry = await add_mock_config(hass) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == snapshot diff --git a/tests/components/advantage_air/test_init.py b/tests/components/advantage_air/test_init.py index c665d038878ddb..21cadbc4b3d953 100644 --- a/tests/components/advantage_air/test_init.py +++ b/tests/components/advantage_air/test_init.py @@ -1,22 +1,17 @@ """Test the Advantage Air Initialization.""" +from unittest.mock import AsyncMock + +from advantage_air import ApiError + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config, patch_get -async def test_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_async_setup_entry(hass: HomeAssistant, mock_get: AsyncMock) -> None: """Test a successful setup entry and unload.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - entry = await add_mock_config(hass) assert entry.state is ConfigEntryState.LOADED @@ -25,15 +20,9 @@ async def test_async_setup_entry( assert entry.state is ConfigEntryState.NOT_LOADED -async def test_async_setup_entry_failure( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_async_setup_entry_failure(hass: HomeAssistant) -> None: """Test a unsuccessful setup entry.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - exc=SyntaxError, - ) - - entry = await add_mock_config(hass) + with patch_get(side_effect=ApiError): + entry = await add_mock_config(hass) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/advantage_air/test_light.py b/tests/components/advantage_air/test_light.py index 0e27b8aec73135..4d21781772d401 100644 --- a/tests/components/advantage_air/test_light.py +++ b/tests/components/advantage_air/test_light.py @@ -1,10 +1,8 @@ """Test the Advantage Air Switch Platform.""" -from json import loads -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, -) + +from unittest.mock import AsyncMock + from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -15,34 +13,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_LIGHT_URL, - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_light( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test light setup.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_LIGHT_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Light Entity @@ -62,13 +43,9 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -76,13 +53,8 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Dimmable Light Entity entity_id = "light.light_b" @@ -98,13 +70,8 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -112,32 +79,17 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 50 - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() async def test_things_light( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test things lights.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -149,7 +101,7 @@ async def test_things_light( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-204" + assert entry.unique_id == f"uniqueid-{light_id}" await hass.services.async_call( LIGHT_DOMAIN, @@ -157,13 +109,8 @@ async def test_things_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -171,10 +118,4 @@ async def test_things_light( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 50 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_select.py b/tests/components/advantage_air/test_select.py index 553c2e60180758..3367595d777dd6 100644 --- a/tests/components/advantage_air/test_select.py +++ b/tests/components/advantage_air/test_select.py @@ -1,5 +1,7 @@ """Test the Advantage Air Select Platform.""" -from json import loads + + +from unittest.mock import AsyncMock from homeassistant.components.select import ( ATTR_OPTION, @@ -10,37 +12,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_select_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test select platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test MyZone Select Entity entity_id = "select.myzone_myzone" state = hass.states.get(entity_id) @@ -57,10 +41,4 @@ async def test_select_async_setup_entry( {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Zone 3"}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["myZone"] == 3 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index e4fab12291d66b..0099e1844c6d85 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -1,6 +1,6 @@ """Test the Advantage Air Sensor Platform.""" from datetime import timedelta -from json import loads +from unittest.mock import AsyncMock from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN from homeassistant.components.advantage_air.sensor import ( @@ -13,37 +13,21 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import add_mock_config from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensor_platform( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test sensor platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test First TimeToOn Sensor entity_id = "sensor.myzone_time_to_on" state = hass.states.get(entity_id) @@ -55,19 +39,15 @@ async def test_sensor_platform( assert entry.unique_id == "uniqueid-ac1-timetoOn" value = 20 + await hass.services.async_call( ADVANTAGE_AIR_DOMAIN, ADVANTAGE_AIR_SERVICE_SET_TIME_TO, {ATTR_ENTITY_ID: [entity_id], ADVANTAGE_AIR_SET_COUNTDOWN_VALUE: value}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["countDownToOn"] == value - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test First TimeToOff Sensor entity_id = "sensor.myzone_time_to_off" @@ -86,13 +66,8 @@ async def test_sensor_platform( {ATTR_ENTITY_ID: [entity_id], ADVANTAGE_AIR_SET_COUNTDOWN_VALUE: value}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 5 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["countDownToOff"] == value - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test First Zone Vent Sensor entity_id = "sensor.myzone_zone_open_with_sensor_vent" @@ -134,11 +109,20 @@ async def test_sensor_platform( assert entry assert entry.unique_id == "uniqueid-ac1-z02-signal" + +async def test_sensor_platform_disabled_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_get: AsyncMock +) -> None: + """Test sensor platform disabled entity.""" + + await add_mock_config(hass) + # Test First Zone Temp Sensor (disabled by default) entity_id = "sensor.myzone_zone_open_with_sensor_temperature" assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -147,6 +131,7 @@ async def test_sensor_platform( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index 99e4c645e71fcb..4977a4cc31f2d0 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -1,10 +1,9 @@ """Test the Advantage Air Switch Platform.""" -from json import loads -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, -) +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -14,37 +13,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_cover_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test switch platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) - # Test Switch Entity + registry = er.async_get(hass) + + # Test Fresh Air Switch Entity entity_id = "switch.myzone_fresh_air" state = hass.states.get(entity_id) assert state @@ -60,12 +45,35 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_update.assert_called_once() + mock_update.reset_mock() + + # Test MyFan Switch Entity + entity_id = "switch.myzone_myfan" + assert hass.states.get(entity_id) == snapshot(name=entity_id) + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "uniqueid-ac1-myfan" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-turnon") + mock_update.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, @@ -73,30 +81,18 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-turnoff") async def test_things_switch( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test things switches.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -108,7 +104,7 @@ async def test_things_switch( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-205" + assert entry.unique_id == f"uniqueid-{thing_id}" await hass.services.async_call( SWITCH_DOMAIN, @@ -116,13 +112,8 @@ async def test_things_switch( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, @@ -130,10 +121,4 @@ async def test_things_switch( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_update.py b/tests/components/advantage_air/test_update.py index 985641b923b8f5..cb180d73f398de 100644 --- a/tests/components/advantage_air/test_update.py +++ b/tests/components/advantage_air/test_update.py @@ -1,25 +1,26 @@ """Test the Advantage Air Update Platform.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import TEST_SYSTEM_URL, add_mock_config +from . import add_mock_config -from tests.common import load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import load_json_object_fixture + +TEST_NEEDS_UPDATE = load_json_object_fixture("needsUpdate.json", DOMAIN) async def test_update_platform( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, ) -> None: """Test update platform.""" - - aioclient_mock.get( - TEST_SYSTEM_URL, - text=load_fixture("advantage_air/needsUpdate.json"), - ) + mock_get.return_value = TEST_NEEDS_UPDATE await add_mock_config(hass) entity_id = "update.testname_app" diff --git a/tests/components/aemet/snapshots/test_weather.ambr b/tests/components/aemet/snapshots/test_weather.ambr index 08cc379267d7f8..9a7b79d94eae12 100644 --- a/tests/components/aemet/snapshots/test_weather.ambr +++ b/tests/components/aemet/snapshots/test_weather.ambr @@ -490,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({ diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index f7ab39b9a71a5a..695087bb738743 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -29,7 +29,8 @@ 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 @@ -122,10 +123,18 @@ async def test_aemet_weather_legacy( 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/aftership/test_config_flow.py b/tests/components/aftership/test_config_flow.py index 2ac5919a5555ad..4668e7a61e4f24 100644 --- a/tests/components/aftership/test_config_flow.py +++ b/tests/components/aftership/test_config_flow.py @@ -77,7 +77,9 @@ async def test_flow_cannot_connect(hass: HomeAssistant, mock_setup_entry) -> Non } -async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: +async def test_import_flow( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_setup_entry +) -> None: """Test importing yaml config.""" with patch( @@ -95,11 +97,12 @@ async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: assert result["data"] == { CONF_API_KEY: "yaml-api-key", } - issue_registry = ir.async_get(hass) assert len(issue_registry.issues) == 1 -async def test_import_flow_already_exists(hass: HomeAssistant) -> None: +async def test_import_flow_already_exists( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test importing yaml config where entry already exists.""" entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "yaml-api-key"}) entry.add_to_hass(hass) @@ -108,3 +111,4 @@ async def test_import_flow_already_exists(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert len(issue_registry.issues) == 1 diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr index a224ea07d46649..c22e96a2082e64 100644 --- a/tests/components/airly/snapshots/test_diagnostics.ambr +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -11,6 +11,7 @@ 'disabled_by': None, 'domain': 'airly', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 80c6de427ca77e..71fda040c1d1ca 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -26,6 +26,7 @@ 'disabled_by': None, 'domain': 'airnow', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, 'options': dict({ 'radius': 150, }), diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 252c12f80fac8a..1619440a6f7fea 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 from aiohttp.client_exceptions import ClientConnectionError import pytest @@ -80,21 +80,6 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_invalid_input(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("aioairq.AirQ.validate", side_effect=InvalidInput): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA | {CONF_IP_ADDRESS: "invalid_ip"} - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_input"} - - async def test_duplicate_error(hass: HomeAssistant) -> None: """Test that errors are shown when duplicates are added.""" MockConfigEntry( diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py index 3a0c852535ea88..3228b3c7229fc5 100644 --- a/tests/components/airthings/test_config_flow.py +++ b/tests/components/airthings/test_config_flow.py @@ -4,7 +4,8 @@ import airthings from homeassistant import config_entries -from homeassistant.components.airthings.const import CONF_ID, CONF_SECRET, DOMAIN +from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr index c805c5f9cb7ab0..cb9d25b8790ab0 100644 --- a/tests/components/airvisual/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -38,6 +38,7 @@ 'disabled_by': None, 'domain': 'airvisual', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, 'options': dict({ 'show_on_map': True, }), diff --git a/tests/components/airvisual_pro/conftest.py b/tests/components/airvisual_pro/conftest.py index 4376db23366742..9ebe13c83e6ba4 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/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr index 96cda8e012fb29..be709621e31e0e 100644 --- a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -93,6 +93,7 @@ 'disabled_by': None, 'domain': 'airvisual_pro', 'entry_id': '6a2b3770e53c28dc1eeb2515e906b0ce', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index 9cb6e550711957..adf0176765c6fd 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -188,7 +188,7 @@ 'coldStages': 0, 'coolmaxtemp': 90, 'coolmintemp': 64, - 'coolsetpoint': 73, + 'coolsetpoint': 77, 'errors': list([ ]), 'floor_demand': 0, @@ -196,7 +196,7 @@ 'heatStages': 0, 'heatmaxtemp': 86, 'heatmintemp': 50, - 'heatsetpoint': 77, + 'heatsetpoint': 73, 'humidity': 0, 'maxTemp': 90, 'minTemp': 64, @@ -240,6 +240,7 @@ 'disabled_by': None, 'domain': 'airzone', 'entry_id': '6e7a0798c1734ba81d26ced0e690eaec', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -601,7 +602,7 @@ 1, ]), 'demand': False, - 'double-set-point': True, + 'double-set-point': False, 'full-name': 'Airzone [2:1] Airzone 2:1', 'heat-stage': 1, 'heat-stages': list([ @@ -644,7 +645,7 @@ 'cold-stage': 0, 'cool-temp-max': 90.0, 'cool-temp-min': 64.0, - 'cool-temp-set': 73.0, + 'cool-temp-set': 77.0, 'demand': True, 'double-set-point': True, 'floor-demand': False, @@ -652,7 +653,7 @@ 'heat-stage': 0, 'heat-temp-max': 86.0, 'heat-temp-min': 50.0, - 'heat-temp-set': 77.0, + 'heat-temp-set': 73.0, 'id': 1, 'master': True, 'mode': 7, diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index 34844e343708e5..f33d1a8b28a682 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -221,7 +221,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_MAX_TEMP) == 32.2 assert state.attributes.get(ATTR_MIN_TEMP) == 17.8 assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP - assert state.attributes.get(ATTR_TEMPERATURE) == 22.8 + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25.0 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 22.8 HVAC_MOCK_CHANGED = copy.deepcopy(HVAC_MOCK) HVAC_MOCK_CHANGED[API_SYSTEMS][0][API_DATA][0][API_MAX_TEMP] = 25 @@ -594,8 +595,8 @@ async def test_airzone_climate_set_temp_range(hass: HomeAssistant) -> None: { API_SYSTEM_ID: 3, API_ZONE_ID: 1, - API_COOL_SET_POINT: 68.0, - API_HEAT_SET_POINT: 77.0, + API_COOL_SET_POINT: 77.0, + API_HEAT_SET_POINT: 68.0, } ] } @@ -618,5 +619,5 @@ async def test_airzone_climate_set_temp_range(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.dkn_plus") - assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 20.0 - assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 25.0 + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25.0 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20.0 diff --git a/tests/components/airzone/test_select.py b/tests/components/airzone/test_select.py index c7c32022123648..01617eab17546b 100644 --- a/tests/components/airzone/test_select.py +++ b/tests/components/airzone/test_select.py @@ -15,6 +15,7 @@ from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .util import async_init_integration @@ -85,7 +86,7 @@ async def test_airzone_select_sleep(hass: HomeAssistant) -> None: ] } - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index a3454549e05044..f83eceaae9c6d6 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -245,10 +245,10 @@ API_ZONE_ID: 1, API_NAME: "DKN Plus", API_ON: 1, - API_COOL_SET_POINT: 73, + API_COOL_SET_POINT: 77, API_COOL_MAX_TEMP: 90, API_COOL_MIN_TEMP: 64, - API_HEAT_SET_POINT: 77, + API_HEAT_SET_POINT: 73, API_HEAT_MAX_TEMP: 86, API_HEAT_MIN_TEMP: 50, API_MAX_TEMP: 90, diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 594a5e6765a42a..d1a8d74cc08663 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -93,6 +93,7 @@ 'disabled_by': None, 'domain': 'airzone_cloud', 'entry_id': 'd186e31edb46d64d14b9b2f11f1ebd9f', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -323,6 +324,12 @@ }), 'systems': dict({ 'system1': dict({ + 'aq-index': 1, + 'aq-pm-1': 3, + 'aq-pm-10': 3, + 'aq-pm-2.5': 4, + 'aq-present': True, + 'aq-status': 'good', 'available': True, 'errors': list([ dict({ @@ -397,6 +404,19 @@ 'zone1': dict({ 'action': 1, 'active': True, + 'aq-active': False, + 'aq-index': 1, + 'aq-mode-conf': 'auto', + 'aq-mode-values': list([ + 'off', + 'on', + 'auto', + ]), + 'aq-pm-1': 3, + 'aq-pm-10': 3, + 'aq-pm-2.5': 4, + 'aq-present': True, + 'aq-status': 'good', 'available': True, 'humidity': 30, 'id': 'zone1', @@ -444,6 +464,19 @@ 'zone2': dict({ 'action': 6, 'active': False, + 'aq-active': False, + 'aq-index': 1, + 'aq-mode-conf': 'auto', + 'aq-mode-values': list([ + 'off', + 'on', + 'auto', + ]), + 'aq-pm-1': 3, + 'aq-pm-10': 3, + 'aq-pm-2.5': 4, + 'aq-present': True, + 'aq-status': 'good', 'available': True, 'humidity': 24, 'id': 'zone2', diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 6924344a092d76..98ff7c65478a99 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -6,6 +6,14 @@ from aioairzone_cloud.common import OperationMode from aioairzone_cloud.const import ( API_ACTIVE, + API_AQ_ACTIVE, + API_AQ_MODE_CONF, + API_AQ_MODE_VALUES, + API_AQ_PM_1, + API_AQ_PM_2P5, + API_AQ_PM_10, + API_AQ_PRESENT, + API_AQ_QUALITY, API_AZ_AIDOO, API_AZ_AIDOO_PRO, API_AZ_SYSTEM, @@ -291,6 +299,12 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: } if device.get_id() == "system1": return { + API_AQ_MODE_VALUES: ["off", "on", "auto"], + API_AQ_PM_1: 3, + API_AQ_PM_2P5: 4, + API_AQ_PM_10: 3, + API_AQ_PRESENT: True, + API_AQ_QUALITY: "good", API_ERRORS: [ { API_OLD_ID: "error-id", @@ -310,6 +324,14 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "zone1": return { API_ACTIVE: True, + API_AQ_ACTIVE: False, + API_AQ_MODE_CONF: "auto", + API_AQ_MODE_VALUES: ["off", "on", "auto"], + API_AQ_PM_1: 3, + API_AQ_PM_2P5: 4, + API_AQ_PM_10: 3, + API_AQ_PRESENT: True, + API_AQ_QUALITY: "good", API_HUMIDITY: 30, API_MODE: OperationMode.COOLING.value, API_MODE_AVAIL: [ @@ -346,6 +368,14 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "zone2": return { API_ACTIVE: False, + API_AQ_ACTIVE: False, + API_AQ_MODE_CONF: "auto", + API_AQ_MODE_VALUES: ["off", "on", "auto"], + API_AQ_PM_1: 3, + API_AQ_PM_2P5: 4, + API_AQ_PM_10: 3, + API_AQ_PRESENT: True, + API_AQ_QUALITY: "good", API_HUMIDITY: 24, API_MODE: OperationMode.COOLING.value, API_MODE_AVAIL: [], diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index 294ec81b9701d0..2fc09d1641de3e 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -7,10 +7,14 @@ from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import DEVICE_CONFIG_OPEN from tests.common import AsyncMock, MockConfigEntry CONFIG = {"username": "test-user", "password": "test-password"} +ID = "533255-1" async def test_setup_get_doors_errors(hass: HomeAssistant) -> None: @@ -40,7 +44,7 @@ async def test_setup_login_error( config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) mock_aladdinconnect_api.login.return_value = False @@ -59,7 +63,7 @@ async def test_setup_connection_error( config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) mock_aladdinconnect_api.login.side_effect = ClientConnectionError @@ -75,7 +79,7 @@ async def test_setup_component_no_error(hass: HomeAssistant) -> None: config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) with patch( @@ -116,7 +120,7 @@ async def test_load_and_unload( config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) @@ -133,3 +137,119 @@ async def test_load_and_unload( assert await config_entry.async_unload(hass) await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_stale_device_removal( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test component setup missing door device is removed.""" + DEVICE_CONFIG_DOOR_2 = { + "device_id": 533255, + "door_number": 2, + "name": "home 2", + "status": "open", + "link_status": "Connected", + "serial": "12346", + "model": "02", + } + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + unique_id=ID, + ) + config_entry.add_to_hass(hass) + mock_aladdinconnect_api.get_doors = AsyncMock( + return_value=[DEVICE_CONFIG_OPEN, DEVICE_CONFIG_DOOR_2] + ) + config_entry_other = MockConfigEntry( + domain="OtherDomain", + data=CONFIG, + unique_id="unique_id", + ) + config_entry_other.add_to_hass(hass) + + device_registry = dr.async_get(hass) + device_entry_other = device_registry.async_get_or_create( + config_entry_id=config_entry_other.entry_id, + identifiers={("OtherDomain", "533255-2")}, + ) + device_registry.async_update_device( + device_entry_other.id, + add_config_entry_id=config_entry.entry_id, + merge_identifiers={(DOMAIN, "533255-2")}, + ) + + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + device_registry = dr.async_get(hass) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + assert len(device_entries) == 2 + assert any((DOMAIN, "533255-1") in device.identifiers for device in device_entries) + assert any((DOMAIN, "533255-2") in device.identifiers for device in device_entries) + assert any( + ("OtherDomain", "533255-2") in device.identifiers for device in device_entries + ) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + assert len(device_entries_other) == 1 + assert any( + (DOMAIN, "533255-2") in device.identifiers for device in device_entries_other + ) + assert any( + ("OtherDomain", "533255-2") in device.identifiers + for device in device_entries_other + ) + + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + mock_aladdinconnect_api.get_doors = AsyncMock(return_value=[DEVICE_CONFIG_OPEN]) + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entries) == 1 + assert any((DOMAIN, "533255-1") in device.identifiers for device in device_entries) + assert not any( + (DOMAIN, "533255-2") in device.identifiers for device in device_entries + ) + assert not any( + ("OtherDomain", "533255-2") in device.identifiers for device in device_entries + ) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + + assert len(device_entries_other) == 1 + assert any( + ("OtherDomain", "533255-2") in device.identifiers + for device in device_entries_other + ) + assert any( + (DOMAIN, "533255-2") in device.identifiers for device in device_entries_other + ) diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py new file mode 100644 index 00000000000000..1e6fce6def6250 --- /dev/null +++ b/tests/components/alarm_control_panel/test_init.py @@ -0,0 +1,70 @@ +"""Test for the alarm control panel const module.""" + +from types import ModuleType + +import pytest + +from homeassistant.components import alarm_control_panel + +from tests.common import import_and_test_deprecated_constant_enum + + +@pytest.mark.parametrize( + "code_format", + list(alarm_control_panel.CodeFormat), +) +@pytest.mark.parametrize( + "module", + [alarm_control_panel, alarm_control_panel.const], +) +def test_deprecated_constant_code_format( + caplog: pytest.LogCaptureFixture, + code_format: alarm_control_panel.CodeFormat, + module: ModuleType, +) -> None: + """Test deprecated format constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, code_format, "FORMAT_", "2025.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(alarm_control_panel.AlarmControlPanelEntityFeature), +) +@pytest.mark.parametrize( + "module", + [alarm_control_panel, alarm_control_panel.const], +) +def test_deprecated_support_alarm_constants( + caplog: pytest.LogCaptureFixture, + entity_feature: alarm_control_panel.AlarmControlPanelEntityFeature, + module: ModuleType, +) -> None: + """Test deprecated support alarm constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, entity_feature, "SUPPORT_ALARM_", "2025.1" + ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockAlarmControlPanelEntity(alarm_control_panel.AlarmControlPanelEntity): + _attr_supported_features = 1 + + entity = MockAlarmControlPanelEntity() + assert ( + entity.supported_features + is alarm_control_panel.AlarmControlPanelEntityFeature(1) + ) + assert "MockAlarmControlPanelEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "AlarmControlPanelEntityFeature.ARM_HOME" in caplog.text + caplog.clear() + assert ( + entity.supported_features + is alarm_control_panel.AlarmControlPanelEntityFeature(1) + ) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/alarm_control_panel/test_significant_change.py b/tests/components/alarm_control_panel/test_significant_change.py new file mode 100644 index 00000000000000..d65a1d5cb00c77 --- /dev/null +++ b/tests/components/alarm_control_panel/test_significant_change.py @@ -0,0 +1,51 @@ +"""Test the Alarm Control Panel significant change platform.""" +import pytest + +from homeassistant.components.alarm_control_panel import ( + ATTR_CHANGED_BY, + ATTR_CODE_ARM_REQUIRED, + ATTR_CODE_FORMAT, +) +from homeassistant.components.alarm_control_panel.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Alarm Control Panel significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_CHANGED_BY: "old_value"}, {ATTR_CHANGED_BY: "old_value"}, False), + ({ATTR_CHANGED_BY: "old_value"}, {ATTR_CHANGED_BY: "new_value"}, True), + ( + {ATTR_CODE_ARM_REQUIRED: "old_value"}, + {ATTR_CODE_ARM_REQUIRED: "new_value"}, + True, + ), + # multiple attributes + ( + {ATTR_CHANGED_BY: "old_value", ATTR_CODE_ARM_REQUIRED: "old_value"}, + {ATTR_CHANGED_BY: "new_value", ATTR_CODE_ARM_REQUIRED: "old_value"}, + True, + ), + # insignificant attributes + ({ATTR_CODE_FORMAT: "old_value"}, {ATTR_CODE_FORMAT: "old_value"}, False), + ({ATTR_CODE_FORMAT: "old_value"}, {ATTR_CODE_FORMAT: "new_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Humidifier significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index a6be57e9ed5610..b83bdb794a8abc 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -8,6 +8,14 @@ from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.valve import ValveEntityFeature +from homeassistant.components.water_heater import ( + ATTR_OPERATION_LIST, + ATTR_OPERATION_MODE, + STATE_ECO, + STATE_GAS, + STATE_HEAT_PUMP, +) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ALARM_ARMED_AWAY, @@ -16,6 +24,7 @@ STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_LOCKED, + STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, @@ -183,7 +192,7 @@ async def test_api_increase_color_temp( ("domain", "payload", "source_list", "idx"), [ ("media_player", "GAME CONSOLE", ["tv", "game console", 10000], 1), - ("media_player", "SATELLITE TV", ["satellite-tv", "game console"], 0), + ("media_player", "SATELLITE TV", ["satellite-tv", "game console", None], 0), ("media_player", "SATELLITE TV", ["satellite_tv", "game console"], 0), ("media_player", "BAD DEVICE", ["satellite_tv", "game console"], None), ], @@ -645,6 +654,143 @@ async def test_report_cover_range_value(hass: HomeAssistant) -> None: properties.assert_equal("Alexa.RangeController", "rangeValue", 0) +async def test_report_valve_range_value(hass: HomeAssistant) -> None: + """Test RangeController reports valve position correctly.""" + all_valve_features = ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION + ) + hass.states.async_set( + "valve.fully_open", + "open", + { + "friendly_name": "Fully open valve", + "current_position": 100, + "supported_features": all_valve_features, + }, + ) + hass.states.async_set( + "valve.half_open", + "open", + { + "friendly_name": "Half open valve", + "current_position": 50, + "supported_features": all_valve_features, + }, + ) + hass.states.async_set( + "valve.closed", + "closed", + { + "friendly_name": "Closed valve", + "current_position": 0, + "supported_features": all_valve_features, + }, + ) + + properties = await reported_properties(hass, "valve.fully_open") + properties.assert_equal("Alexa.RangeController", "rangeValue", 100) + + properties = await reported_properties(hass, "valve.half_open") + properties.assert_equal("Alexa.RangeController", "rangeValue", 50) + + properties = await reported_properties(hass, "valve.closed") + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) + + +@pytest.mark.parametrize( + ( + "supported_features", + "has_mode_controller", + "has_range_controller", + "has_toggle_controller", + ), + [ + (ValveEntityFeature(0), False, False, False), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + True, + False, + True, + ), + ( + ValveEntityFeature.OPEN, + True, + False, + False, + ), + ( + ValveEntityFeature.CLOSE, + True, + False, + False, + ), + ( + ValveEntityFeature.STOP, + False, + False, + True, + ), + ( + ValveEntityFeature.SET_POSITION, + False, + True, + False, + ), + ( + ValveEntityFeature.STOP | ValveEntityFeature.SET_POSITION, + False, + True, + True, + ), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.SET_POSITION, + False, + True, + False, + ), + ], +) +async def test_report_valve_controllers( + hass: HomeAssistant, + supported_features: ValveEntityFeature, + has_mode_controller: bool, + has_range_controller: bool, + has_toggle_controller: bool, +) -> None: + """Test valve controllers are reported correctly.""" + hass.states.async_set( + "valve.custom", + "opening", + { + "friendly_name": "Custom valve", + "current_position": 0, + "supported_features": supported_features, + }, + ) + + properties = await reported_properties(hass, "valve.custom") + + if has_mode_controller: + properties.assert_equal("Alexa.ModeController", "mode", "state.opening") + else: + properties.assert_not_has_property("Alexa.ModeController", "mode") + if has_range_controller: + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) + else: + properties.assert_not_has_property("Alexa.RangeController", "rangeValue") + if has_toggle_controller: + properties.assert_equal("Alexa.ToggleController", "toggleState", "OFF") + else: + properties.assert_not_has_property("Alexa.ToggleController", "toggleState") + + async def test_report_climate_state(hass: HomeAssistant) -> None: """Test ThermostatController reports state correctly.""" for auto_modes in (HVACMode.AUTO, HVACMode.HEAT_COOL): @@ -777,6 +923,96 @@ async def test_report_climate_state(hass: HomeAssistant) -> None: assert msg["event"]["payload"]["type"] == "INTERNAL_ERROR" +async def test_report_water_heater_state(hass: HomeAssistant) -> None: + """Test ThermostatController also reports state correctly for water heaters.""" + for operation_mode in (STATE_ECO, STATE_GAS, STATE_HEAT_PUMP): + hass.states.async_set( + "water_heater.boyler", + operation_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_OPERATION_LIST: [STATE_ECO, STATE_GAS, STATE_HEAT_PUMP], + ATTR_OPERATION_MODE: operation_mode, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_equal( + "Alexa.ModeController", "mode", f"operation_mode.{operation_mode}" + ) + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + for off_mode in [STATE_OFF]: + hass.states.async_set( + "water_heater.boyler", + off_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_not_has_property("Alexa.ModeController", "mode") + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + for state in "unavailable", "unknown": + hass.states.async_set( + f"water_heater.{state}", + state, + {"friendly_name": f"Boyler {state}", "supported_features": 11}, + ) + properties = await reported_properties(hass, f"water_heater.{state}") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_not_has_property("Alexa.ModeController", "mode") + + +async def test_report_singe_mode_water_heater(hass: HomeAssistant) -> None: + """Test ThermostatController also reports state correctly for water heaters.""" + operation_mode = STATE_ECO + hass.states.async_set( + "water_heater.boyler", + operation_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_OPERATION_LIST: [STATE_ECO], + ATTR_OPERATION_MODE: operation_mode, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property("Alexa.ThermostatController", "thermostatMode") + properties.assert_equal( + "Alexa.ModeController", "mode", f"operation_mode.{operation_mode}" + ) + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + async def test_temperature_sensor_sensor(hass: HomeAssistant) -> None: """Test TemperatureSensor reports sensor temperature correctly.""" for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"): @@ -823,6 +1059,29 @@ async def test_temperature_sensor_climate(hass: HomeAssistant) -> None: ) +async def test_temperature_sensor_water_heater(hass: HomeAssistant) -> None: + """Test TemperatureSensor reports climate temperature correctly.""" + for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"): + hass.states.async_set( + "water_heater.boyler", + STATE_ECO, + {"supported_features": 11, ATTR_CURRENT_TEMPERATURE: bad_value}, + ) + + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property("Alexa.TemperatureSensor", "temperature") + + hass.states.async_set( + "water_heater.boyler", + STATE_ECO, + {"supported_features": 11, ATTR_CURRENT_TEMPERATURE: 34}, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 34.0, "scale": "CELSIUS"} + ) + + async def test_report_alarm_control_panel_state(hass: HomeAssistant) -> None: """Test SecurityPanelController implements armState property.""" hass.states.async_set("alarm_control_panel.armed_away", STATE_ALARM_ARMED_AWAY, {}) @@ -864,6 +1123,7 @@ async def test_report_playback_state(hass: HomeAssistant) -> None: | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP, "volume_level": 0.75, + "source_list": [None], }, ) diff --git a/tests/components/alexa/test_common.py b/tests/components/alexa/test_common.py index 4cbe112af499e8..d3ea1bcda3ec6d 100644 --- a/tests/components/alexa/test_common.py +++ b/tests/components/alexa/test_common.py @@ -128,12 +128,14 @@ async def assert_request_calls_service( async def assert_request_fails( - namespace, name, endpoint, service_not_called, hass, payload=None + namespace, name, endpoint, service_not_called, hass, payload=None, instance=None ): """Assert an API request returns an ErrorResponse.""" request = get_new_request(namespace, name, endpoint) if payload: request["directive"]["payload"] = payload + if instance: + request["directive"]["header"]["instance"] = instance domain, service_name = service_not_called.split(".") call = async_mock_service(hass, domain, service_name) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index e24ec4c950bc02..ff8fef43a66f1f 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -6,11 +6,17 @@ from homeassistant.components.alexa import smart_home, state_report import homeassistant.components.camera as camera -from homeassistant.components.cover import CoverDeviceClass +from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.valve import SERVICE_STOP_VALVE, ValveEntityFeature from homeassistant.config import async_process_ha_core_config -from homeassistant.const import STATE_UNKNOWN, UnitOfTemperature +from homeassistant.const import ( + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + STATE_UNKNOWN, + UnitOfTemperature, +) from homeassistant.core import Context, Event, HomeAssistant from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component @@ -156,7 +162,7 @@ def assert_endpoint_capabilities(endpoint, *interfaces): capabilities = endpoint["capabilities"] supported = {feature["interface"] for feature in capabilities} - assert supported == set(interfaces) + assert supported == {interface for interface in interfaces if interface is not None} return capabilities @@ -1439,6 +1445,8 @@ async def test_media_player_inputs(hass: HomeAssistant) -> None: "aux", "input 1", "tv", + 0, + None, ], }, ) @@ -1882,7 +1890,91 @@ async def test_group(hass: HomeAssistant) -> None: ) -async def test_cover_position_range(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("position", "position_attr_in_service_call", "supported_features", "service_call"), + [ + ( + 30, + 30, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ( + 0, + None, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.close_cover", + ), + ( + 99, + 99, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ( + 100, + None, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.open_cover", + ), + ( + 0, + 0, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 60, + 60, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 0, + 0, + CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN, + "cover.set_cover_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_POSITION | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ], + ids=[ + "position_30_open_close", + "position_0_open_close", + "position_99_open_close", + "position_100_open_close", + "position_0_no_open_close", + "position_60_no_open_close", + "position_100_no_open_close", + "position_0_no_close", + "position_100_no_open", + ], +) +async def test_cover_position( + hass: HomeAssistant, + position: int, + position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, +) -> None: """Test cover discovery and position using rangeController.""" device = ( "cover.test_range", @@ -1890,8 +1982,8 @@ async def test_cover_position_range(hass: HomeAssistant) -> None: { "friendly_name": "Test cover range", "device_class": "blind", - "supported_features": 7, - "position": 30, + "supported_features": supported_features, + "position": position, }, ) appliance = await discovery_test(device, hass) @@ -1967,58 +2059,322 @@ async def test_cover_position_range(hass: HomeAssistant) -> None: "range": {"minimumValue": 1, "maximumValue": 100}, } in position_state_mappings - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.set_cover_position", - hass, - payload={"rangeValue": 50}, - instance="cover.position", - ) - assert call.data["position"] == 50 - call, msg = await assert_request_calls_service( "Alexa.RangeController", "SetRangeValue", "cover#test_range", - "cover.close_cover", + service_call, hass, - payload={"rangeValue": 0}, + payload={"rangeValue": position}, instance="cover.position", ) + assert call.data.get("position") == position_attr_in_service_call properties = msg["context"]["properties"][0] assert properties["name"] == "rangeValue" assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 0 + assert properties["value"] == position + + +@pytest.mark.parametrize( + ( + "position", + "position_attr_in_service_call", + "supported_features", + "service_call", + "has_toggle_controller", + ), + [ + ( + 30, + 30, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + "valve.set_valve_position", + True, + ), + ( + 0, + None, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.close_valve", + False, + ), + ( + 99, + 99, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.set_valve_position", + False, + ), + ( + 100, + None, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.open_valve", + False, + ), + ( + 0, + 0, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 60, + 60, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 60, + 60, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP, + "valve.set_valve_position", + True, + ), + ( + 100, + 100, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 0, + 0, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.OPEN, + "valve.set_valve_position", + False, + ), + ( + 100, + 100, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.CLOSE, + "valve.set_valve_position", + False, + ), + ], + ids=[ + "position_30_open_close_stop", + "position_0_open_close", + "position_99_open_close", + "position_100_open_close", + "position_0_no_open_close", + "position_60_no_open_close", + "position_60_stop_no_open_close", + "position_100_no_open_close", + "position_0_no_close", + "position_100_no_open", + ], +) +async def test_valve_position( + hass: HomeAssistant, + position: int, + position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, + has_toggle_controller: bool, +) -> None: + """Test cover discovery and position using rangeController.""" + device = ( + "valve.test_range", + "open", + { + "friendly_name": "Test valve range", + "device_class": "water", + "supported_features": supported_features, + "position": position, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_range" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController" if has_toggle_controller else None, + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "valve.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Opening", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings call, msg = await assert_request_calls_service( "Alexa.RangeController", "SetRangeValue", - "cover#test_range", - "cover.open_cover", + "valve#test_range", + service_call, hass, - payload={"rangeValue": 100}, - instance="cover.position", + payload={"rangeValue": position}, + instance="valve.position", ) + assert call.data.get("position") == position_attr_in_service_call properties = msg["context"]["properties"][0] assert properties["name"] == "rangeValue" assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 + assert properties["value"] == position - call, msg = await assert_request_calls_service( + +async def test_cover_position_range( + hass: HomeAssistant, +) -> None: + """Test cover discovery and position range using rangeController. + + Also tests an invalid cover position being handled correctly. + """ + + device = ( + "cover.test_range", + "open", + { + "friendly_name": "Test cover range", + "device_class": "blind", + "supported_features": 7, + "position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test_range" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", "Alexa.RangeController", - "AdjustRangeValue", - "cover#test_range", - "cover.open_cover", - hass, - payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False}, - instance="cover.position", + "Alexa.EndpointHealth", + "Alexa", ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "cover.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Position", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Lower", "Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Raise", "Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings call, msg = await assert_request_calls_service( "Alexa.RangeController", @@ -2046,17 +2402,219 @@ async def test_cover_position_range(hass: HomeAssistant) -> None: ) -async def assert_percentage_changes( - hass, adjustments, namespace, name, endpoint, parameter, service, changed_parameter -): - """Assert an API request making percentage changes works. +async def test_valve_position_range( + hass: HomeAssistant, +) -> None: + """Test valve discovery and position range using rangeController. - AdjustPercentage, AdjustBrightness, etc. are examples of such requests. + Also tests an invalid valve position being handled correctly. """ - for result_volume, adjustment in adjustments: - payload = {parameter: adjustment} if parameter else {} - call, _ = await assert_request_calls_service( - namespace, name, endpoint, service, hass, payload=payload + + device = ( + "valve.test_range", + "open", + { + "friendly_name": "Test valve range", + "device_class": "water", + "supported_features": 15, + "position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_range" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "valve.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Opening", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.open_valve", + hass, + payload={"rangeValueDelta": 101, "rangeValueDeltaDefault": False}, + instance="valve.position", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == 100 + assert call.service == SERVICE_OPEN_VALVE + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.close_valve", + hass, + payload={"rangeValueDelta": -99, "rangeValueDeltaDefault": False}, + instance="valve.position", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == 0 + assert call.service == SERVICE_CLOSE_VALVE + + await assert_range_changes( + hass, + [(25, -5, False), (35, 5, False), (50, 1, True), (10, -1, True)], + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.set_valve_position", + "position", + instance="valve.position", + ) + + +@pytest.mark.parametrize( + ("supported_features", "state_controller"), + [ + ( + ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP, + "Alexa.RangeController", + ), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + "Alexa.ModeController", + ), + ], +) +async def test_stop_valve( + hass: HomeAssistant, supported_features: ValveEntityFeature, state_controller: str +) -> None: + """Test stop valve ToggleController.""" + device = ( + "valve.test", + "opening", + { + "friendly_name": "Test valve", + "supported_features": supported_features, + "current_position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve" + capabilities = assert_endpoint_capabilities( + appliance, + state_controller, + "Alexa.ToggleController", + "Alexa.EndpointHealth", + "Alexa", + ) + + toggle_capability = get_capability(capabilities, "Alexa.ToggleController") + assert toggle_capability is not None + assert toggle_capability["instance"] == "valve.stop" + + properties = toggle_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "toggleState"} in properties["supported"] + + capability_resources = toggle_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Stop", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + call, _ = await assert_request_calls_service( + "Alexa.ToggleController", + "TurnOn", + "valve#test", + "valve.stop_valve", + hass, + payload={}, + instance="valve.stop", + ) + assert call.data["entity_id"] == "valve.test" + assert call.service == SERVICE_STOP_VALVE + + +async def assert_percentage_changes( + hass, adjustments, namespace, name, endpoint, parameter, service, changed_parameter +): + """Assert an API request making percentage changes works. + + AdjustPercentage, AdjustBrightness, etc. are examples of such requests. + """ + for result_volume, adjustment in adjustments: + payload = {parameter: adjustment} if parameter else {} + call, _ = await assert_request_calls_service( + namespace, name, endpoint, service, hass, payload=payload ) assert call.data[changed_parameter] == result_volume @@ -2560,6 +3118,181 @@ async def test_thermostat(hass: HomeAssistant) -> None: assert call.data["preset_mode"] == "eco" +async def test_water_heater(hass: HomeAssistant) -> None: + """Test water_heater discovery.""" + hass.config.units = US_CUSTOMARY_SYSTEM + device = ( + "water_heater.boyler", + "gas", + { + "temperature": 70.0, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 75.0, + "friendly_name": "Test water heater", + "supported_features": 1 | 2 | 8, + "operation_list": ["off", "gas", "eco"], + "operation_mode": "gas", + "min_temp": 50, + "max_temp": 90, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "water_heater#boyler" + assert appliance["displayCategories"][0] == "WATER_HEATER" + assert appliance["friendlyName"] == "Test water heater" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ThermostatController", + "Alexa.ModeController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "water_heater#boyler") + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.gas") + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 70.0, "scale": "FAHRENHEIT"}, + ) + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 75.0, "scale": "FAHRENHEIT"} + ) + + modes_capability = get_capability(capabilities, "Alexa.ModeController") + assert modes_capability is not None + configuration = modes_capability["configuration"] + + supported_modes = ["operation_mode.off", "operation_mode.gas", "operation_mode.eco"] + for mode in supported_modes: + assert mode in [item["value"] for item in configuration["supportedModes"]] + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpoint": {"value": 69.0, "scale": "FAHRENHEIT"}}, + ) + assert call.data["temperature"] == 69.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 69.0, "scale": "FAHRENHEIT"}, + ) + + msg = await assert_request_fails( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpoint": {"value": 0.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={ + "targetSetpoint": {"value": 30.0, "scale": "CELSIUS"}, + }, + ) + assert call.data["temperature"] == 86.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 86.0, "scale": "FAHRENHEIT"}, + ) + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": -10.0, "scale": "KELVIN"}}, + ) + assert call.data["temperature"] == 52.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 52.0, "scale": "FAHRENHEIT"}, + ) + + msg = await assert_request_fails( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": 20.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + # Setting mode, the payload can be an object with a value attribute... + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.eco"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "eco" + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.eco") + + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.gas"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "gas" + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.gas") + + # assert unsupported mode + msg = await assert_request_fails( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.invalid"}, + instance="water_heater.operation_mode", + ) + assert msg["event"]["payload"]["type"] == "INVALID_VALUE" + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.off"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "off" + + async def test_no_current_target_temp_adjusting_temp(hass: HomeAssistant) -> None: """Test thermostat adjusting temp with no initial target temperature.""" hass.config.units = US_CUSTOMARY_SYSTEM @@ -3352,6 +4085,137 @@ async def test_cover_position_mode(hass: HomeAssistant) -> None: assert properties["value"] == "position.custom" +async def test_valve_position_mode(hass: HomeAssistant) -> None: + """Test valve discovery and position using modeController.""" + device = ( + "valve.test_mode", + "open", + { + "friendly_name": "Test valve mode", + "device_class": "water", + "supported_features": ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_mode" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve mode" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.ModeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController", + "Alexa", + ) + + mode_capability = get_capability(capabilities, "Alexa.ModeController") + assert mode_capability is not None + assert mode_capability["instance"] == "valve.state" + + properties = mode_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "mode"} in properties["supported"] + + capability_resources = mode_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Preset", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Preset"}, + } in capability_resources["friendlyNames"] + + configuration = mode_capability["configuration"] + assert configuration is not None + assert configuration["ordered"] is False + + supported_modes = configuration["supportedModes"] + assert supported_modes is not None + assert { + "value": "state.open", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "Open", "locale": "en-US"}}, + {"@type": "asset", "value": {"assetId": "Alexa.Setting.Preset"}}, + ] + }, + } in supported_modes + assert { + "value": "state.closed", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "Closed", "locale": "en-US"}}, + {"@type": "asset", "value": {"assetId": "Alexa.Setting.Preset"}}, + ] + }, + } in supported_modes + + # Assert for Position Semantics + position_semantics = mode_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetMode", "payload": {"mode": "state.closed"}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetMode", "payload": {"mode": "state.open"}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": "state.closed", + } in position_state_mappings + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Open"], + "value": "state.open", + } in position_state_mappings + + _, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "valve#test_mode", + "valve.close_valve", + hass, + payload={"mode": "state.closed"}, + instance="valve.state", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "state.closed" + + _, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "valve#test_mode", + "valve.open_valve", + hass, + payload={"mode": "state.open"}, + instance="valve.state", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "state.open" + + async def test_image_processing(hass: HomeAssistant) -> None: """Test image_processing discovery as event detection.""" device = ( @@ -3433,7 +4297,100 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: assert {"name": "humanPresenceDetectionState"} in properties["supported"] -async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ( + "tilt_position", + "tilt_position_attr_in_service_call", + "supported_features", + "service_call", + ), + [ + ( + 30, + 30, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.set_cover_tilt_position", + ), + ( + 0, + None, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.close_cover_tilt", + ), + ( + 99, + 99, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.set_cover_tilt_position", + ), + ( + 100, + None, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.open_cover_tilt", + ), + ( + 0, + 0, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 60, + 60, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 0, + 0, + CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.OPEN_TILT, + "cover.set_cover_tilt_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.CLOSE_TILT, + "cover.set_cover_tilt_position", + ), + ], + ids=[ + "tilt_position_30_open_close", + "tilt_position_0_open_close", + "tilt_position_99_open_close", + "tilt_position_100_open_close", + "tilt_position_0_no_open_close", + "tilt_position_60_no_open_close", + "tilt_position_100_no_open_close", + "tilt_position_0_no_close", + "tilt_position_100_no_open", + ], +) +async def test_cover_tilt_position( + hass: HomeAssistant, + tilt_position: int, + tilt_position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, +) -> None: """Test cover discovery and tilt position using rangeController.""" device = ( "cover.test_tilt_range", @@ -3441,8 +4398,8 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: { "friendly_name": "Test cover tilt range", "device_class": "blind", - "supported_features": 240, - "tilt_position": 30, + "supported_features": supported_features, + "tilt_position": tilt_position, }, ) appliance = await discovery_test(device, hass) @@ -3472,58 +4429,74 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: state_mappings = semantics["stateMappings"] assert state_mappings is not None - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_tilt_range", - "cover.set_cover_tilt_position", - hass, - payload={"rangeValue": 50}, - instance="cover.tilt", - ) - assert call.data["tilt_position"] == 50 - call, msg = await assert_request_calls_service( "Alexa.RangeController", "SetRangeValue", "cover#test_tilt_range", - "cover.close_cover_tilt", + service_call, hass, - payload={"rangeValue": 0}, + payload={"rangeValue": tilt_position}, instance="cover.tilt", ) + assert call.data.get("tilt_position") == tilt_position_attr_in_service_call properties = msg["context"]["properties"][0] assert properties["name"] == "rangeValue" assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 0 + assert properties["value"] == tilt_position - call, msg = await assert_request_calls_service( + +async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: + """Test cover discovery and tilt position range using rangeController. + + Also tests and invalid tilt position being handled correctly. + """ + device = ( + "cover.test_tilt_range", + "open", + { + "friendly_name": "Test cover tilt range", + "device_class": "blind", + "supported_features": 240, + "tilt_position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test_tilt_range" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover tilt range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", "Alexa.RangeController", - "SetRangeValue", - "cover#test_tilt_range", - "cover.open_cover_tilt", - hass, - payload={"rangeValue": 100}, - instance="cover.tilt", + "Alexa.EndpointHealth", + "Alexa", ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - call, msg = await assert_request_calls_service( + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "cover.tilt" + + semantics = range_capability["semantics"] + assert semantics is not None + + action_mappings = semantics["actionMappings"] + assert action_mappings is not None + + state_mappings = semantics["stateMappings"] + assert state_mappings is not None + + call, _ = await assert_request_calls_service( "Alexa.RangeController", - "AdjustRangeValue", + "SetRangeValue", "cover#test_tilt_range", - "cover.open_cover_tilt", + "cover.set_cover_tilt_position", hass, - payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False}, + payload={"rangeValue": 50}, instance="cover.tilt", ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 + assert call.data["tilt_position"] == 50 call, msg = await assert_request_calls_service( "Alexa.RangeController", diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr index 4b231660c4b82e..b4aede7948c737 100644 --- a/tests/components/ambient_station/snapshots/test_diagnostics.ambr +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'ambient_station', 'entry_id': '382cf7643f016fd48b3fe52163fe8877', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 4e51880c75419a..d22738a7e6b27c 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 5bcb84cb974435..aa58ee5bbb5b5b 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/anthemav/conftest.py b/tests/components/anthemav/conftest.py index 7797f08872fe80..4c1abdd3c9b809 100644 --- a/tests/components/anthemav/conftest.py +++ b/tests/components/anthemav/conftest.py @@ -4,8 +4,8 @@ import pytest -from homeassistant.components.anthemav.const import CONF_MODEL, DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.components.anthemav.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/aosmith/__init__.py b/tests/components/aosmith/__init__.py new file mode 100644 index 00000000000000..89845dda42eeb7 --- /dev/null +++ b/tests/components/aosmith/__init__.py @@ -0,0 +1 @@ +"""Tests for the A. O. Smith integration.""" diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py new file mode 100644 index 00000000000000..61c1fc9a562e92 --- /dev/null +++ b/tests/components/aosmith/conftest.py @@ -0,0 +1,82 @@ +"""Common fixtures for the A. O. Smith tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from py_aosmith import AOSmithAPIClient +import pytest + +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + +FIXTURE_USER_INPUT = { + CONF_EMAIL: "testemail@example.com", + CONF_PASSWORD: "test-password", +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=FIXTURE_USER_INPUT, + unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aosmith.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def get_devices_fixture() -> str: + """Return the name of the fixture to use for get_devices.""" + return "get_devices" + + +@pytest.fixture +async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked client.""" + get_devices_fixture = load_json_array_fixture(f"{get_devices_fixture}.json", DOMAIN) + get_energy_use_fixture = load_json_object_fixture( + "get_energy_use_data.json", DOMAIN + ) + + client_mock = MagicMock(AOSmithAPIClient) + client_mock.get_devices = AsyncMock(return_value=get_devices_fixture) + client_mock.get_energy_use_data = AsyncMock(return_value=get_energy_use_fixture) + + return client_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> MockConfigEntry: + """Set up the integration for testing.""" + hass.config.units = US_CUSTOMARY_SYSTEM + + with patch( + "homeassistant.components.aosmith.AOSmithAPIClient", return_value=mock_client + ): + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/aosmith/fixtures/get_devices.json b/tests/components/aosmith/fixtures/get_devices.json new file mode 100644 index 00000000000000..e34c50cd270ae2 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_mode_pending.json b/tests/components/aosmith/fixtures/get_devices_mode_pending.json new file mode 100644 index 00000000000000..a12f1d95f13768 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_mode_pending.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": true, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json new file mode 100644 index 00000000000000..249024e1f1eccf --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json @@ -0,0 +1,42 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json new file mode 100644 index 00000000000000..4d6e7613cf23e1 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": true, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_energy_use_data.json b/tests/components/aosmith/fixtures/get_energy_use_data.json new file mode 100644 index 00000000000000..989ddab5399800 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_energy_use_data.json @@ -0,0 +1,19 @@ +{ + "average": 2.7552000000000003, + "graphData": [ + { + "date": "2023-10-30T04:00:00.000Z", + "kwh": 2.01 + }, + { + "date": "2023-10-31T04:00:00.000Z", + "kwh": 1.542 + }, + { + "date": "2023-11-01T04:00:00.000Z", + "kwh": 1.908 + } + ], + "lifetimeKwh": 132.825, + "startDate": "Oct 30" +} diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr new file mode 100644 index 00000000000000..fb80dc06917d08 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'basement', + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'aosmith', + 'junctionId', + ), + }), + 'is_new': False, + 'manufacturer': 'A. O. Smith', + 'model': 'HPTS-50 200 202172000', + 'name': 'My water heater', + 'name_by_user': None, + 'serial_number': 'serial', + 'suggested_area': 'Basement', + 'sw_version': '2.14', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/aosmith/snapshots/test_sensor.ambr b/tests/components/aosmith/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..d4376c64a01420 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_sensor.ambr @@ -0,0 +1,35 @@ +# serializer version: 1 +# name: test_state[sensor.my_water_heater_energy_usage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'My water heater Energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_water_heater_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '132.825', + }) +# --- +# name: test_state[sensor.my_water_heater_hot_water_availability] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'My water heater Hot water availability', + 'icon': 'mdi:water-thermometer', + 'options': list([ + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_water_heater_hot_water_availability', + 'last_changed': , + 'last_updated': , + 'state': 'low', + }) +# --- diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000000..2293a6c7b65e96 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_water_heater.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': None, + 'friendly_name': 'My water heater', + 'max_temp': 130, + 'min_temp': 95, + 'operation_list': list([ + 'eco', + 'heat_pump', + 'electric', + ]), + 'operation_mode': 'heat_pump', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 130, + }), + 'context': , + 'entity_id': 'water_heater.my_water_heater', + 'last_changed': , + 'last_updated': , + 'state': 'heat_pump', + }) +# --- diff --git a/tests/components/aosmith/test_config_flow.py b/tests/components/aosmith/test_config_flow.py new file mode 100644 index 00000000000000..d6cf1655b14c3b --- /dev/null +++ b/tests/components/aosmith/test_config_flow.py @@ -0,0 +1,191 @@ +"""Test the A. O. Smith config flow.""" +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from py_aosmith import AOSmithInvalidCredentialsException +import pytest + +from homeassistant import config_entries +from homeassistant.components.aosmith.const import ( + DOMAIN, + ENERGY_USAGE_INTERVAL, + REGULAR_INTERVAL, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.aosmith.conftest import FIXTURE_USER_INPUT + + +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.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result2["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_error_key"), + [ + (AOSmithInvalidCredentialsException("Invalid credentials"), "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_form_exception( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error_key: str, +) -> None: + """Test handling an exception and then recovering on the second attempt.""" + 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.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_error_key} + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result3["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("api_method", "wait_interval"), + [ + ("get_devices", REGULAR_INTERVAL), + ("get_energy_use_data", ENERGY_USAGE_INTERVAL), + ], +) +async def test_reauth_flow( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, + api_method: str, + wait_interval: timedelta, +) -> None: + """Test reauth works.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + getattr(mock_client, api_method).side_effect = AOSmithInvalidCredentialsException( + "Authentication error" + ) + freezer.tick(wait_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ), patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_energy_use_data", + return_value=[], + ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_flow_retry( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test reauth works with retry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException( + "Authentication error" + ) + freezer.tick(REGULAR_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # First attempt at reauth - authentication fails again + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=AOSmithInvalidCredentialsException("Authentication error"), + ): + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # Second attempt at reauth - authentication succeeds + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): + result3 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/aosmith/test_device.py b/tests/components/aosmith/test_device.py new file mode 100644 index 00000000000000..596f380290e8fe --- /dev/null +++ b/tests/components/aosmith/test_device.py @@ -0,0 +1,23 @@ +"""Tests for the device created by the A. O. Smith integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test creation of the device.""" + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "junctionId")}, + ) + + assert reg_device == snapshot diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py new file mode 100644 index 00000000000000..463932e930a2e9 --- /dev/null +++ b/tests/components/aosmith/test_init.py @@ -0,0 +1,97 @@ +"""Tests for the initialization of the A. O. Smith integration.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from py_aosmith import AOSmithUnknownException +import pytest + +from homeassistant.components.aosmith.const import ( + DOMAIN, + FAST_INTERVAL, + REGULAR_INTERVAL, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_array_fixture, +) + + +async def test_config_entry_setup(init_integration: MockConfigEntry) -> None: + """Test setup of the config entry.""" + mock_config_entry = init_integration + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready_get_devices_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the config entry not ready when get_devices fails.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=AOSmithUnknownException("Unknown error"), + ): + 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_config_entry_not_ready_get_energy_use_data_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the config entry not ready when get_energy_use_data fails.""" + mock_config_entry.add_to_hass(hass) + + get_devices_fixture = load_json_array_fixture("get_devices.json", DOMAIN) + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=get_devices_fixture, + ), patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_energy_use_data", + side_effect=AOSmithUnknownException("Unknown error"), + ): + 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 + + +@pytest.mark.parametrize( + ("get_devices_fixture", "time_to_wait", "expected_call_count"), + [ + ("get_devices", REGULAR_INTERVAL, 1), + ("get_devices", FAST_INTERVAL, 0), + ("get_devices_mode_pending", FAST_INTERVAL, 1), + ("get_devices_setpoint_pending", FAST_INTERVAL, 1), + ], +) +async def test_update( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + time_to_wait: timedelta, + expected_call_count: int, +) -> None: + """Test data update with differing intervals depending on device status.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert mock_client.get_devices.call_count == 1 + + freezer.tick(time_to_wait) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_client.get_devices.call_count == 1 + expected_call_count diff --git a/tests/components/aosmith/test_sensor.py b/tests/components/aosmith/test_sensor.py new file mode 100644 index 00000000000000..f94dfdb710cfc1 --- /dev/null +++ b/tests/components/aosmith/test_sensor.py @@ -0,0 +1,50 @@ +"""Tests for the sensor platform of the A. O. Smith integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id", "unique_id"), + [ + ( + "sensor.my_water_heater_hot_water_availability", + "hot_water_availability_junctionId", + ), + ("sensor.my_water_heater_energy_usage", "energy_usage_junctionId"), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + entity_id: str, + unique_id: str, +) -> None: + """Test the setup of the sensor entities.""" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == unique_id + + +@pytest.mark.parametrize( + ("entity_id"), + [ + "sensor.my_water_heater_hot_water_availability", + "sensor.my_water_heater_energy_usage", + ], +) +async def test_state( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test the state of the sensor entities.""" + state = hass.states.get(entity_id) + assert state == snapshot diff --git a/tests/components/aosmith/test_water_heater.py b/tests/components/aosmith/test_water_heater.py new file mode 100644 index 00000000000000..61cb159c82ab93 --- /dev/null +++ b/tests/components/aosmith/test_water_heater.py @@ -0,0 +1,147 @@ +"""Tests for the water heater platform of the A. O. Smith integration.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aosmith.const import ( + AOSMITH_MODE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP, + AOSMITH_MODE_HYBRID, + AOSMITH_MODE_VACATION, +) +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_OPERATION_MODE, + ATTR_TEMPERATURE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + WaterHeaterEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test the setup of the water heater entity.""" + entry = entity_registry.async_get("water_heater.my_water_heater") + assert entry + assert entry.unique_id == "junctionId" + + state = hass.states.get("water_heater.my_water_heater") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My water heater" + + +async def test_state( + hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: + """Test the state of the water heater entity.""" + state = hass.states.get("water_heater.my_water_heater") + assert state == snapshot + + +@pytest.mark.parametrize( + ("get_devices_fixture"), + ["get_devices_no_vacation_mode"], +) +async def test_state_away_mode_unsupported( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test that away mode is not supported if the water heater does not support vacation mode.""" + state = hass.states.get("water_heater.my_water_heater") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + +@pytest.mark.parametrize( + ("hass_mode", "aosmith_mode"), + [ + (STATE_HEAT_PUMP, AOSMITH_MODE_HEAT_PUMP), + (STATE_ECO, AOSMITH_MODE_HYBRID), + (STATE_ELECTRIC, AOSMITH_MODE_ELECTRIC), + ], +) +async def test_set_operation_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_mode: str, + aosmith_mode: str, +) -> None: + """Test setting the operation mode.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_OPERATION_MODE: hass_mode, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) + + +async def test_set_temperature( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test setting the target temperature.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "water_heater.my_water_heater", ATTR_TEMPERATURE: 120}, + ) + await hass.async_block_till_done() + + mock_client.update_setpoint.assert_called_once_with("junctionId", 120) + + +@pytest.mark.parametrize( + ("hass_away_mode", "aosmith_mode"), + [ + (True, AOSMITH_MODE_VACATION), + (False, AOSMITH_MODE_HYBRID), + ], +) +async def test_away_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_away_mode: bool, + aosmith_mode: str, +) -> None: + """Test turning away mode on/off.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_AWAY_MODE: hass_away_mode, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index b8a83f950d0a04..4c4e0af8705e4c 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -95,9 +95,7 @@ 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("aioapcaccess.request_status", return_value=status): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 6ac7992f4043dd..6a69d4e974ec5e 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -24,7 +24,7 @@ def _patch_setup(): async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: """Test config flow setup with connection error.""" - with patch("apcaccess.status.get") as mock_get: + with patch("aioapcaccess.request_status") as mock_get: mock_get.side_effect = OSError() result = await hass.config_entries.flow.async_init( @@ -38,10 +38,7 @@ 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("aioapcaccess.request_status", return_value={}): # Returns no status. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -63,10 +60,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(): - mock_parse.return_value = MOCK_STATUS + with ( + patch("aioapcaccess.request_status") as mock_request_status, + _patch_setup(), + ): + mock_request_status.return_value = MOCK_STATUS # Now, create the integration again using the same config data, we should reject # the creation due same host / port. @@ -96,7 +94,7 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: # Now we change the serial number and add it again. This should be successful. another_device_status = copy(MOCK_STATUS) another_device_status["SERIALNO"] = MOCK_STATUS["SERIALNO"] + "ZZZ" - mock_parse.return_value = another_device_status + mock_request_status.return_value = another_device_status result = await hass.config_entries.flow.async_init( DOMAIN, @@ -109,9 +107,10 @@ 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("aioapcaccess.request_status", return_value=MOCK_STATUS), + _patch_setup() as mock_setup, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, @@ -147,11 +146,12 @@ 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("aioapcaccess.request_status") as mock_request_status, + _patch_setup() as mock_setup, + ): status = MOCK_MINIMAL_STATUS | extra_status - mock_parse.return_value = status + mock_request_status.return_value = status result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 9bdcc89a9a35d9..c65efe25bb93e2 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -1,18 +1,21 @@ """Test init of APCUPSd integration.""" +import asyncio from collections import OrderedDict 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)) @@ -67,11 +70,11 @@ async def test_device_entry( 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" @@ -95,7 +98,11 @@ async def test_multiple_integrations(hass: HomeAssistant) -> None: assert state1.state != state2.state -async def test_connection_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "error", + (OSError(), asyncio.IncompleteReadError(partial=b"", expected=0)), +) +async def test_connection_error(hass: HomeAssistant, error: Exception) -> None: """Test connection error during integration setup.""" entry = MockConfigEntry( version=1, @@ -107,15 +114,13 @@ 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("aioapcaccess.request_status", side_effect=error): 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 +147,38 @@ 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("aioapcaccess.request_status") as mock_request_status: + # Mock a network error and then trigger an auto-polling event. + mock_request_status.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_request_status.side_effect = None + mock_request_status.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 743b1f87847b29..24aae1d3937606 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,17 +11,23 @@ ) 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, entity_registry: er.EntityRegistry) -> None: """Test states of sensor.""" @@ -105,3 +115,88 @@ async def test_sensor_disabled( 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("aioapcaccess.request_status", return_value=new_status): + 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("aioapcaccess.request_status") as mock_request_status: + mock_request_status.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_request_status.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( + "aioapcaccess.request_status", return_value=MOCK_STATUS + ) as mock_request_status: + # 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_request_status.call_count == 1 diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index f97b55c3ede53a..08cb77b45591c5 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,26 +353,41 @@ def listener(service_call): assert state["attributes"] == {"data": 1} -async def test_api_call_service_timeout( +async def test_api_call_service_client_closed( hass: HomeAssistant, mock_api_client: TestClient ) -> None: - """Test if the API does not fail on long running services.""" + """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) - with patch("homeassistant.components.api.SERVICE_WAIT_TIMEOUT", 0): - await mock_api_client.post("/api/services/test_domain/test_service") - assert len(test_value) == 0 - fut.set_result(1) - await hass.async_block_till_done() + 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 diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index cc56894cf0d1ec..807eff4ef8defb 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_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index e822759d208261..128f54790777a8 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -48,14 +48,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -67,7 +67,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }), 'type': , @@ -75,9 +75,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }), 'type': , @@ -137,14 +137,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -156,7 +156,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'Arnold Schwarzenegger', }), 'type': , @@ -164,9 +164,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=Arnold+Schwarzenegger', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_2657c1a8ee_test.mp3', }), }), 'type': , @@ -226,14 +226,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -245,7 +245,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'Arnold Schwarzenegger', }), 'type': , @@ -253,9 +253,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=Arnold+Schwarzenegger', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_2657c1a8ee_test.mp3', }), }), 'type': , @@ -338,14 +338,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -357,7 +357,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }), 'type': , @@ -365,9 +365,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }), 'type': , diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 9eb7e1e5a050c3..31b1c44e67e7d2 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -46,14 +46,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -64,16 +64,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -127,14 +127,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -145,16 +145,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_debug.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -220,14 +220,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -238,16 +238,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_with_enhancements.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -421,14 +421,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -439,16 +439,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.8 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -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,51 @@ '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({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'homeassistant', + 'intent_input': 'never mind', + 'language': 'en', + }) +# --- +# name: test_pipeline_empty_tts_output.2 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + }) +# --- +# name: test_pipeline_empty_tts_output.3 + None +# --- # name: test_stt_provider_missing dict({ 'language': 'en', @@ -613,14 +771,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No area named are', }), }), }), diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 24a4a92536d85f..882d3a80fb3d5f 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,4 +1,5 @@ """Test Voice Assistant init.""" +import asyncio from dataclasses import asdict import itertools as it from pathlib import Path @@ -569,6 +570,69 @@ async def audio_data(): ) +async def test_pipeline_saved_audio_empty_queue( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_supporting_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that saved audio thread closes WAV file even if there's an empty queue.""" + with tempfile.TemporaryDirectory() as temp_dir_str: + # Enable audio recording to temporary directory + temp_dir = Path(temp_dir_str) + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_DEBUG_RECORDING_DIR: temp_dir_str}}, + ) + + def event_callback(event: assist_pipeline.PipelineEvent): + if event.type == "run-end": + # Verify WAV file exists, but contains no data + pipeline_dirs = list(temp_dir.iterdir()) + run_dirs = list(pipeline_dirs[0].iterdir()) + wav_path = next(run_dirs[0].iterdir()) + with wave.open(str(wav_path), "rb") as wav_file: + assert wav_file.getnframes() == 0 + + async def audio_data(): + # Force timeout in _pipeline_debug_recording_thread_proc + await asyncio.sleep(1) + yield b"not used" + + # Wrap original function to time out immediately + _pipeline_debug_recording_thread_proc = ( + assist_pipeline.pipeline._pipeline_debug_recording_thread_proc + ) + + def proc_wrapper(run_recording_dir, queue): + _pipeline_debug_recording_thread_proc( + run_recording_dir, queue, message_timeout=0 + ) + + with patch( + "homeassistant.components.assist_pipeline.pipeline._pipeline_debug_recording_thread_proc", + proc_wrapper, + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=event_callback, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.STT, + ) + + async def test_wake_word_detection_aborted( hass: HomeAssistant, mock_stt_provider: MockSttProvider, diff --git a/tests/components/assist_pipeline/test_logbook.py b/tests/components/assist_pipeline/test_logbook.py new file mode 100644 index 00000000000000..c1e0633ed57dfe --- /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_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 5a84f4c27162fe..35913df74001d1 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1,6 +1,7 @@ """Websocket tests for Voice Assistant integration.""" +from collections.abc import AsyncGenerator from typing import Any -from unittest.mock import ANY, AsyncMock, patch +from unittest.mock import ANY, patch import pytest @@ -16,14 +17,22 @@ async_create_default_pipeline, async_get_pipeline, async_get_pipelines, + async_update_pipeline, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import MANY_LANGUAGES -from .conftest import MockSttPlatform, MockSttProvider, MockTTSPlatform, MockTTSProvider +from .conftest import MockSttProvider, MockTTSProvider -from tests.common import MockModule, flush_store, mock_integration, mock_platform +from tests.common import flush_store + + +@pytest.fixture(autouse=True) +async def delay_save_fixture() -> AsyncGenerator[None, None]: + """Load the homeassistant integration.""" + with patch("homeassistant.helpers.collection.SAVE_DELAY", new=0): + yield @pytest.fixture(autouse=True) @@ -237,13 +246,26 @@ async def test_create_default_pipeline( store = pipeline_data.pipeline_store assert len(store.data) == 1 - assert await async_create_default_pipeline(hass, "bla", "bla") is None - assert await async_create_default_pipeline(hass, "test", "test") == Pipeline( + assert ( + await async_create_default_pipeline( + hass, + stt_engine_id="bla", + tts_engine_id="bla", + pipeline_name="Bla pipeline", + ) + is None + ) + assert await async_create_default_pipeline( + hass, + stt_engine_id="test", + tts_engine_id="test", + pipeline_name="Test pipeline", + ) == Pipeline( conversation_engine="homeassistant", conversation_language="en", id=ANY, language="en", - name="Home Assistant", + name="Test pipeline", stt_engine="test", stt_language="en-US", tts_engine="test", @@ -467,51 +489,123 @@ async def test_default_pipeline_unsupported_tts_language( ) -async def test_default_pipeline_cloud( +async def test_update_pipeline( hass: HomeAssistant, - mock_stt_provider: MockSttProvider, - mock_tts_provider: MockTTSProvider, + hass_storage: dict[str, Any], ) -> None: - """Test async_get_pipeline.""" + """Test async_update_pipeline.""" + assert await async_setup_component(hass, "assist_pipeline", {}) - mock_integration(hass, MockModule("cloud")) - mock_platform( - hass, - "cloud.tts", - MockTTSPlatform( - async_get_engine=AsyncMock(return_value=mock_tts_provider), - ), - ) - mock_platform( + pipelines = async_get_pipelines(hass) + pipelines = list(pipelines) + assert pipelines == [ + Pipeline( + conversation_engine="homeassistant", + conversation_language="en", + id=ANY, + language="en", + name="Home Assistant", + stt_engine=None, + stt_language=None, + tts_engine=None, + tts_language=None, + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + ] + + pipeline = pipelines[0] + await async_update_pipeline( hass, - "cloud.stt", - MockSttPlatform( - async_get_engine=AsyncMock(return_value=mock_stt_provider), - ), + pipeline, + conversation_engine="homeassistant_1", + conversation_language="de", + language="de", + name="Home Assistant 1", + stt_engine="stt.test_1", + stt_language="de", + tts_engine="test_1", + tts_language="de", + tts_voice="test_voice", + wake_word_entity="wake_work.test_1", + wake_word_id="wake_word_id_1", ) - mock_platform(hass, "test.config_flow") - - assert await async_setup_component(hass, "tts", {"tts": {"platform": "cloud"}}) - assert await async_setup_component(hass, "stt", {"stt": {"platform": "cloud"}}) - assert await async_setup_component(hass, "assist_pipeline", {}) - pipeline_data: PipelineData = hass.data[DOMAIN] - store = pipeline_data.pipeline_store - assert len(store.data) == 1 + pipelines = async_get_pipelines(hass) + pipelines = list(pipelines) + pipeline = pipelines[0] + assert pipelines == [ + Pipeline( + conversation_engine="homeassistant_1", + conversation_language="de", + id=pipeline.id, + language="de", + name="Home Assistant 1", + stt_engine="stt.test_1", + stt_language="de", + tts_engine="test_1", + tts_language="de", + tts_voice="test_voice", + wake_word_entity="wake_work.test_1", + wake_word_id="wake_word_id_1", + ) + ] + assert len(hass_storage[STORAGE_KEY]["data"]["items"]) == 1 + assert hass_storage[STORAGE_KEY]["data"]["items"][0] == { + "conversation_engine": "homeassistant_1", + "conversation_language": "de", + "id": pipeline.id, + "language": "de", + "name": "Home Assistant 1", + "stt_engine": "stt.test_1", + "stt_language": "de", + "tts_engine": "test_1", + "tts_language": "de", + "tts_voice": "test_voice", + "wake_word_entity": "wake_work.test_1", + "wake_word_id": "wake_word_id_1", + } - # Check the default pipeline - pipeline = async_get_pipeline(hass, None) - assert pipeline == Pipeline( - conversation_engine="homeassistant", - conversation_language="en", - id=pipeline.id, - language="en", - name="Home Assistant Cloud", - stt_engine="cloud", - stt_language="en-US", - tts_engine="cloud", - tts_language="en-US", - tts_voice="james_earl_jones", - wake_word_entity=None, - wake_word_id=None, + await async_update_pipeline( + hass, + pipeline, + stt_engine="stt.test_2", + stt_language="en", + tts_engine="test_2", + tts_language="en", ) + + pipelines = async_get_pipelines(hass) + pipelines = list(pipelines) + assert pipelines == [ + Pipeline( + conversation_engine="homeassistant_1", + conversation_language="de", + id=pipeline.id, + language="de", + name="Home Assistant 1", + stt_engine="stt.test_2", + stt_language="en", + tts_engine="test_2", + tts_language="en", + tts_voice="test_voice", + wake_word_entity="wake_work.test_1", + wake_word_id="wake_word_id_1", + ) + ] + assert len(hass_storage[STORAGE_KEY]["data"]["items"]) == 1 + assert hass_storage[STORAGE_KEY]["data"]["items"][0] == { + "conversation_engine": "homeassistant_1", + "conversation_language": "de", + "id": pipeline.id, + "language": "de", + "name": "Home Assistant 1", + "stt_engine": "stt.test_2", + "stt_language": "en", + "tts_engine": "test_2", + "tts_language": "en", + "tts_voice": "test_voice", + "wake_word_entity": "wake_work.test_1", + "wake_word_id": "wake_word_id_1", + } diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 9e70e65e0a813d..c4e750e1019267 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -20,7 +20,7 @@ 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 @@ async def async_setup_entry( @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") diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 9a4e78a29afb8e..458320a9a90c14 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,394 @@ 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": "intent", + "end_stage": "tts", + "input": { + "text": "never mind", + }, + } + ) + + # 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"]) + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + 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 index 8572584d65f0e8..d3953416281981 100644 --- a/tests/components/asuswrt/common.py +++ b/tests/components/asuswrt/common.py @@ -1,10 +1,13 @@ """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, ) @@ -40,6 +43,14 @@ 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", @@ -48,6 +59,8 @@ ] -def new_device(mac, ip, name): +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 index 7596e94549d836..7710e26707cea5 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -4,16 +4,24 @@ 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_CURRENT_TRANSFER_RATES = [20000000, 10000000] -MOCK_LOAD_AVG = [1.1, 1.2, 1.3] -MOCK_TEMPERATURES = {"2.4GHz": 40.2, "CPU": 71.2} +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 = {"2.4GHz": 40.2, "5.0GHz": 0, "CPU": 71.2} +MOCK_TEMPERATURES_HTTP = {**MOCK_TEMPERATURES, "5.0GHz_2": 40.3, "6.0GHz": 40.4} @pytest.fixture(name="patch_setup_entry") @@ -29,8 +37,17 @@ def mock_controller_patch_setup_entry(): def mock_devices_legacy_fixture(): """Mock a list of devices.""" return { - MOCK_MACS[0]: new_device(MOCK_MACS[0], "192.168.1.2", "Test"), - MOCK_MACS[1]: new_device(MOCK_MACS[1], "192.168.1.3", "TestTwo"), + 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"), } @@ -81,3 +98,48 @@ def mock_controller_connect_legacy_sens_fail(connect_legacy): 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 = { + k: v for k, v in MOCK_TEMPERATURES_HTTP.items() if k != "5.0GHz" + } + 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_HTTP], + ) 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 ec81c4a256ab1a..0b5b0ace720485 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -2,6 +2,7 @@ from socket import gaierror from unittest.mock import patch +from pyasuswrt import AsusWrtError import pytest from homeassistant import data_entry_flow @@ -13,18 +14,54 @@ 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_HOST, CONF_MODE, CONF_PASSWORD +from homeassistant.const import ( + CONF_BASE, + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant -from .common import ASUSWRT_BASE, CONFIG_DATA_TELNET, HOST, ROUTER_MAC_ADDR +from .common import ASUSWRT_BASE, HOST, ROUTER_MAC_ADDR from tests.common import MockConfigEntry SSH_KEY = "1234" +CONFIG_DATA = { + CONF_HOST: HOST, + CONF_USERNAME: "user", + CONF_PASSWORD: "pwd", +} + +CONFIG_DATA_HTTP = { + **CONFIG_DATA, + CONF_PROTOCOL: PROTOCOL_HTTPS, + CONF_PORT: 8443, +} + +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="patch_get_host", autouse=True) def mock_controller_patch_get_host(): @@ -45,7 +82,7 @@ def mock_controller_patch_is_file(): @pytest.mark.parametrize("unique_id", [{}, {"label_mac": ROUTER_MAC_ADDR}]) -async def test_user( +async def test_user_legacy( hass: HomeAssistant, connect_legacy, patch_setup_entry, unique_id ) -> None: """Test user config.""" @@ -58,30 +95,70 @@ async def test_user( connect_legacy.return_value.async_get_nvram.return_value = unique_id # test with all provided + 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 legacy_result["type"] == data_entry_flow.FlowResultType.FORM + assert legacy_result["step_id"] == "legacy" + + # complete configuration result = await hass.config_entries.flow.async_configure( - flow_result["flow_id"], - user_input=CONFIG_DATA_TELNET, + 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 + assert result["data"] == {**CONFIG_DATA_TELNET, CONF_MODE: MODE_AP} assert len(patch_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("config", "error"), - [ - ({}, "pwd_or_ssh"), - ({CONF_PASSWORD: "pwd", CONF_SSH_KEY: SSH_KEY}, "pwd_and_ssh"), - ], -) -async def test_error_wrong_password_ssh(hass: HomeAssistant, config, error) -> None: +@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}, + data=config_data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {CONF_BASE: "pwd_required"} + + +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_TELNET.items() if k != CONF_PASSWORD} - config_data.update(config) + 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}, @@ -89,12 +166,12 @@ 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_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 = {k: v for k, v in CONFIG_DATA_TELNET.items() if k != 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 patch_is_file.return_value = False @@ -105,7 +182,7 @@ async def test_error_invalid_ssh(hass: HomeAssistant, patch_is_file) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": "ssh_not_file"} + assert result["errors"] == {CONF_BASE: "ssh_not_file"} async def test_error_invalid_host(hass: HomeAssistant, patch_get_host) -> None: @@ -118,7 +195,7 @@ async def test_error_invalid_host(hass: HomeAssistant, patch_get_host) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {CONF_BASE: "invalid_host"} async def test_abort_if_not_unique_id_setup(hass: HomeAssistant) -> None: @@ -138,27 +215,26 @@ async def test_abort_if_not_unique_id_setup(hass: HomeAssistant) -> None: async def test_update_uniqueid_exist( - hass: HomeAssistant, connect_legacy, patch_setup_entry + hass: HomeAssistant, connect_http, patch_setup_entry ) -> None: """Test we update entry if uniqueid is already configured.""" existing_entry = MockConfigEntry( domain=DOMAIN, - data={**CONFIG_DATA_TELNET, CONF_HOST: "10.10.10.10"}, + 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 result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}, - data=CONFIG_DATA_TELNET, + 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_TELNET + assert result["data"] == CONFIG_DATA_HTTP prev_entry = hass.config_entries.async_get_entry(existing_entry.entry_id) assert not prev_entry @@ -190,10 +266,10 @@ async def test_abort_invalid_unique_id(hass: HomeAssistant, connect_legacy) -> N (None, "cannot_connect"), ], ) -async def test_on_connect_failed( +async def test_on_connect_legacy_failed( hass: HomeAssistant, connect_legacy, side_effect, error ) -> None: - """Test when we have errors connecting the router.""" + """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}, @@ -202,11 +278,43 @@ async def test_on_connect_failed( connect_legacy.return_value.is_connected = False connect_legacy.return_value.connection.async_connect.side_effect = side_effect + # 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} + + +@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"] == {"base": error} + assert result["errors"] == {CONF_BASE: error} async def test_options_flow_ap(hass: HomeAssistant, patch_setup_entry) -> None: @@ -251,7 +359,7 @@ async def test_options_flow_router(hass: HomeAssistant, patch_setup_entry) -> No """Test config flow options for router mode.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA_TELNET, + data={**CONFIG_DATA_TELNET, CONF_MODE: MODE_ROUTER}, ) config_entry.add_to_hass(hass) @@ -280,3 +388,36 @@ async def test_options_flow_router(hass: HomeAssistant, patch_setup_entry) -> No 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_sensor.py b/tests/components/asuswrt/test_sensor.py index 92f40dd8511b79..e3122f1dfef36e 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -1,6 +1,7 @@ """Tests for the AsusWrt sensor.""" from datetime import timedelta +from pyasuswrt.asuswrt import AsusWrtError import pytest from homeassistant.components import device_tracker, sensor @@ -11,33 +12,48 @@ SENSORS_LOAD_AVG, SENSORS_RATES, SENSORS_TEMPERATURES, + SENSORS_TEMPERATURES_LEGACY, ) from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_PROTOCOL, + STATE_HOME, + STATE_NOT_HOME, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import slugify from homeassistant.util.dt import utcnow -from .common import CONFIG_DATA_TELNET, HOST, MOCK_MACS, ROUTER_MAC_ADDR, new_device +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 SENSORS_DEFAULT = [*SENSORS_BYTES, *SENSORS_RATES] -SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] +SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES_LEGACY] +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_MACS[2], MOCK_MACS[3])): - dev_reg.async_get_or_create( + 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))}, @@ -131,8 +147,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_MACS[2]] = new_device(MOCK_MACS[2], "192.168.1.4", "TestThree") - mock_devices[MOCK_MACS[3]] = new_device(MOCK_MACS[3], "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( @@ -153,7 +173,7 @@ async def _test_sensors( "entry_unique_id", [None, ROUTER_MAC_ADDR], ) -async def test_sensors( +async def test_sensors_legacy( hass: HomeAssistant, connect_legacy, mock_devices_legacy, @@ -164,11 +184,24 @@ async def test_sensors( await _test_sensors(hass, mock_devices_legacy, CONFIG_DATA_TELNET, entry_unique_id) -async def test_loadavg_sensors(hass: HomeAssistant, connect_legacy) -> None: +@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_TELNET, SENSORS_LOAD_AVG - ) + config_entry, sensor_prefix = _setup_entry(hass, config, SENSORS_LOAD_AVG) config_entry.add_to_hass(hass) # initial devices setup @@ -183,30 +216,82 @@ async def test_loadavg_sensors(hass: HomeAssistant, connect_legacy) -> None: assert hass.states.get(f"{sensor_prefix}_sensor_load_avg15").state == "1.3" -async def test_temperature_sensors(hass: HomeAssistant, connect_legacy) -> None: - """Test creating a AsusWRT temperature sensors.""" +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_TELNET, SENSORS_TEMPERATURES + 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") + assert not hass.states.get(f"{sensor_prefix}_5_0ghz_2") + assert not hass.states.get(f"{sensor_prefix}_6_0ghz") + + +async def _test_temperature_sensors(hass: HomeAssistant, config, sensors) -> str: + """Test creating a AsusWRT temperature sensors.""" + config_entry, sensor_prefix = _setup_entry(hass, config, sensors) + 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() async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() + return sensor_prefix + + +async def test_temperature_sensors_legacy(hass: HomeAssistant, connect_legacy) -> None: + """Test creating a AsusWRT temperature sensors.""" + sensor_prefix = await _test_temperature_sensors( + hass, CONFIG_DATA_TELNET, SENSORS_TEMPERATURES_LEGACY + ) # assert temperature sensor available assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" + assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" assert not hass.states.get(f"{sensor_prefix}_5_0ghz") + + +async def test_temperature_sensors_http(hass: HomeAssistant, connect_http) -> None: + """Test creating a AsusWRT temperature sensors.""" + sensor_prefix = await _test_temperature_sensors( + hass, CONFIG_DATA_HTTP, SENSORS_TEMPERATURES + ) + # assert temperature sensor available + assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" + assert hass.states.get(f"{sensor_prefix}_5_0ghz_2").state == "40.3" + assert hass.states.get(f"{sensor_prefix}_6_0ghz").state == "40.4" + assert not hass.states.get(f"{sensor_prefix}_5_0ghz") @pytest.mark.parametrize( "side_effect", [OSError, None], ) -async def test_connect_fail(hass: HomeAssistant, connect_legacy, side_effect) -> None: +async def test_connect_fail_legacy( + hass: HomeAssistant, connect_legacy, side_effect +) -> None: """Test AsusWRT connect fail.""" # init config entry @@ -225,22 +310,43 @@ async def test_connect_fail(hass: HomeAssistant, connect_legacy, side_effect) -> assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_sensors_polling_fails( - hass: HomeAssistant, connect_legacy_sens_fail +@pytest.mark.parametrize( + "side_effect", + [AsusWrtError, None], +) +async def test_connect_fail_http( + hass: HomeAssistant, connect_http, side_effect ) -> None: - """Test AsusWRT sensors are unavailable when polling fails.""" - config_entry, sensor_prefix = _setup_entry( - hass, CONFIG_DATA_TELNET, SENSORS_ALL_LEGACY + """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, sensors) + 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() async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() - for sensor_name in SENSORS_ALL_LEGACY: + for sensor_name in sensors: assert ( hass.states.get(f"{sensor_prefix}_{slugify(sensor_name)}").state == STATE_UNAVAILABLE @@ -248,6 +354,23 @@ async def test_sensors_polling_fails( assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "0" +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( @@ -274,7 +397,9 @@ async def test_options_reload(hass: HomeAssistant, connect_legacy) -> None: assert connect_legacy.return_value.connection.async_connect.call_count == 2 -async def test_unique_id_migration(hass: HomeAssistant, connect_legacy) -> 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, @@ -283,9 +408,8 @@ async def test_unique_id_migration(hass: HomeAssistant, connect_legacy) -> None: ) 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} {ROUTER_MAC_ADDR} Upload", @@ -297,6 +421,31 @@ async def test_unique_id_migration(hass: HomeAssistant, connect_legacy) -> 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"{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_LEGACY] + 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/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index b30da3ce348ff5..d156dce2154be3 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 f88cab0cb46a58..92b448d86453aa 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 8fbe29f99794d0..a78682ced6d13c 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 @@ 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 @@ -62,6 +62,8 @@ async def test_sensors(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns, + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -95,14 +97,18 @@ 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.alarms", return_value=["No alarm"] + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=_simulated_returns, ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -128,16 +134,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"), - ): - async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=AuroraTimeoutError("No response after 3 tries"), + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): + 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 - ): - async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=_simulated_returns, + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): + 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 +160,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"), - ): - async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=AuroraError("No response after 10 seconds"), + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): + 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 +178,9 @@ 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( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] + ), 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 7ce6596408628d..8b731934913ce5 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 @@ 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_init.py b/tests/components/auth/test_init.py index a33ca702bcf74b..4088b1819fa93b 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -4,6 +4,7 @@ import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth import InvalidAuthError @@ -167,28 +168,25 @@ async def test_auth_code_checks_local_only_user( assert error["error"] == "access_denied" -def test_auth_code_store_expiration(mock_credential) -> None: +def test_auth_code_store_expiration( + mock_credential, freezer: FrozenDateTimeFactory +) -> None: """Test that the auth code store will not return expired tokens.""" store, retrieve = auth._create_auth_code_store() client_id = "bla" now = utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", return_value=now + timedelta(minutes=10) - ): - assert retrieve(client_id, code) is None + freezer.move_to(now + timedelta(minutes=10)) + assert retrieve(client_id, code) is None - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", - return_value=now + timedelta(minutes=9, seconds=59), - ): - assert retrieve(client_id, code) == mock_credential + freezer.move_to(now + timedelta(minutes=9, seconds=59)) + assert retrieve(client_id, code) == mock_credential def test_auth_code_store_requires_credentials(mock_credential) -> None: diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index b44d8fb4a11541..27652ca2be4184 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -1,25 +1,130 @@ """Tests for the login flow.""" from http import HTTPStatus +from typing import Any from unittest.mock import patch +import pytest + from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component -from . import async_setup_auth +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, +) -> None: + """Test fetching auth providers for homeassistant auth provider.""" + client = await async_setup_auth( + hass, aiohttp_client, [{"type": "homeassistant"}], custom_ip=ip + ) + + expected = { + "name": "Home Assistant Local", + "type": "homeassistant", + "id": None, + } + + 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) + + +@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, + ) async def test_fetch_auth_providers_onboarding( diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6d83b00517d8ff..235ca48f095e42 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import logging +from typing import Any from unittest.mock import Mock, patch import pytest @@ -46,6 +47,7 @@ SCRIPT_MODE_SINGLE, _async_stop_scripts_at_shutdown, ) +from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo from homeassistant.setup import async_setup_component from homeassistant.util import yaml import homeassistant.util.dt as dt_util @@ -57,6 +59,7 @@ async_capture_events, async_fire_time_changed, async_mock_service, + import_and_test_deprecated_constant, mock_restore_cache, ) from tests.components.logbook.common import MockRow, mock_humanify @@ -1102,7 +1105,7 @@ async def test_reload_automation_when_blueprint_changes( autospec=True, return_value=config, ), patch( - "homeassistant.components.blueprint.models.yaml.load_yaml", + "homeassistant.components.blueprint.models.yaml.load_yaml_dict", autospec=True, return_value=blueprint_config, ): @@ -2564,3 +2567,22 @@ async def test_websocket_config( msg = await client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "not_found" + + +@pytest.mark.parametrize( + ("constant_name", "replacement"), + [ + ("AutomationActionType", TriggerActionType), + ("AutomationTriggerData", TriggerData), + ("AutomationTriggerInfo", TriggerInfo), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, automation, constant_name, replacement.__name__, replacement, "2025.1" + ) diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr index 74a1f110c142e1..9960fc9bfd2d23 100644 --- a/tests/components/axis/snapshots/test_diagnostics.ambr +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -41,6 +41,7 @@ 'disabled_by': None, 'domain': 'axis', 'entry_id': '676abe5b73621446e6550a2e86ffe3dd', + 'minor_version': 1, 'options': dict({ 'events': True, }), diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 9d3b9889cd3a13..e23f86e545ba52 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 e97887b154a348..ee5f2bc353c5a4 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 4967bcdfa38f78..6ba0661ae557b6 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -26,6 +26,7 @@ ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ServiceValidationError from . import init_integration @@ -38,7 +39,7 @@ HVACMode.AUTO, ] -ENTITY_CLIMATE = "climate.fakespa_climate" +ENTITY_CLIMATE = "climate.fakespa" async def test_spa_defaults( @@ -146,7 +147,7 @@ async def test_spa_preset_modes( assert state assert state.attributes[ATTR_PRESET_MODE] == mode - with pytest.raises(KeyError): + with pytest.raises(ServiceValidationError): await common.async_set_preset_mode(hass, 2, ENTITY_CLIMATE) # put it in RNR and test assertion diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 437a2e1efa6adc..014722d94a4f36 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -14,6 +14,7 @@ MockConfigEntry, MockModule, MockPlatform, + import_and_test_deprecated_constant_enum, mock_config_flow, mock_integration, mock_platform, @@ -102,7 +103,7 @@ async def async_setup_entry_platform( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test binary_sensor platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) mock_platform( @@ -172,7 +173,7 @@ async def async_setup_entry_platform( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test binary_sensor platform via config entry.""" async_add_entities([entity1, entity2]) mock_platform( @@ -194,3 +195,17 @@ async def async_setup_entry_platform( "Entity binary_sensor.test2 cannot be added as the entity category is set to config" in caplog.text ) + + +@pytest.mark.parametrize( + "device_class", + list(binary_sensor.BinarySensorDeviceClass), +) +def test_deprecated_constant_device_class( + caplog: pytest.LogCaptureFixture, + device_class: binary_sensor.BinarySensorDeviceClass, +) -> None: + """Test deprecated binary sensor device classes.""" + import_and_test_deprecated_constant_enum( + caplog, binary_sensor, device_class, "DEVICE_CLASS_", "2025.1" + ) diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py index 25ab8cab8cb54a..3c05a425b1205d 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 2e6e5de45736a7..6ea6d995900088 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 cbf8f5e589bc13..8691c886faabbc 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 e2184df9820d2d..47f38ba815b083 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 1cfe36e70b6292..68990a09a3216a 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 5a425e799c30b0..db98a2705b2887 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 00000000000000..d7deaf39bd9fca --- /dev/null +++ b/tests/components/blink/conftest.py @@ -0,0 +1,98 @@ +"""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", + "unique_id": "an_email@email.com", + "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 00000000000000..44554dad1e35b7 --- /dev/null +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -0,0 +1,54 @@ +# 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', + 'unique_id': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'blink', + 'minor_version': 1, + '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 ab04499c827b00..ada38451754d66 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 00000000000000..d447203dae6516 --- /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 00000000000000..f3d9beaf21a3c5 --- /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 00000000000000..1c2faa32d04f27 --- /dev/null +++ b/tests/components/blink/test_services.py @@ -0,0 +1,225 @@ +"""Test the Blink services.""" +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from homeassistant.components.blink.const import ( + ATTR_CONFIG_ENTRY_ID, + DOMAIN, + SERVICE_REFRESH, + SERVICE_SEND_PIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +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_pin_service_calls( + hass: HomeAssistant, + 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() + + 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_CONFIG_ENTRY_ID: [mock_config_entry.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_CONFIG_ENTRY_ID: ["bad-config_id"], CONF_PIN: PIN}, + blocking=True, + ) + + +async def test_service_pin_called_with_non_blink_device( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pin 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" + other_mock_config_entry = MockConfigEntry( + title="Not Blink", domain=other_domain, entry_id=other_config_id + ) + await hass.config_entries.async_add(other_mock_config_entry) + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = { + ATTR_CONFIG_ENTRY_ID: [other_mock_config_entry.entry_id], + CONF_PIN: PIN, + } + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + parameters, + blocking=True, + ) + + +async def test_service_update_called_with_non_blink_device( + hass: HomeAssistant, + mock_blink_api: MagicMock, + device_registry: dr.DeviceRegistry, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test update 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" + other_mock_config_entry = MockConfigEntry( + title="Not Blink", domain=other_domain, entry_id=other_config_id + ) + await hass.config_entries.async_add(other_mock_config_entry) + + 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]} + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH, + parameters, + blocking=True, + ) + + +async def test_service_pin_called_with_unloaded_entry( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pin service calls with not ready 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() + mock_config_entry.state = ConfigEntryState.SETUP_ERROR + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = {ATTR_CONFIG_ENTRY_ID: [mock_config_entry.entry_id], CONF_PIN: PIN} + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + parameters, + blocking=True, + ) + + +async def test_service_update_called_with_unloaded_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test update service calls with not ready 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() + + mock_config_entry.state = ConfigEntryState.SETUP_ERROR + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) + assert device_entry + + parameters = {ATTR_DEVICE_ID: [device_entry.id]} + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH, + parameters, + blocking=True, + ) diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py new file mode 100644 index 00000000000000..901c776a894d41 --- /dev/null +++ b/tests/components/blue_current/__init__.py @@ -0,0 +1,52 @@ +"""Tests for the Blue Current integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from bluecurrent_api import Client + +from homeassistant.components.blue_current import DOMAIN, Connector +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, platform, data: dict, grid=None +) -> MockConfigEntry: + """Set up the Blue Current integration in Home Assistant.""" + + if grid is None: + grid = {} + + def init( + self: Connector, hass: HomeAssistant, config: ConfigEntry, client: Client + ) -> None: + """Mock grid and charge_points.""" + + self.config = config + self.hass = hass + self.client = client + self.charge_points = data + self.grid = grid + self.available = True + + with patch( + "homeassistant.components.blue_current.PLATFORMS", [platform] + ), patch.object(Connector, "__init__", init), patch( + "homeassistant.components.blue_current.Client", autospec=True + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + async_dispatcher_send(hass, "blue_current_value_update_101") + return config_entry diff --git a/tests/components/blue_current/test_config_flow.py b/tests/components/blue_current/test_config_flow.py new file mode 100644 index 00000000000000..057701235adbe8 --- /dev/null +++ b/tests/components/blue_current/test_config_flow.py @@ -0,0 +1,149 @@ +"""Test the Blue Current config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.blue_current import DOMAIN +from homeassistant.components.blue_current.config_flow import ( + AlreadyConnected, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) +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 if the form is created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + +async def test_user(hass: HomeAssistant) -> None: + """Test if the api token is set.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + with patch( + "homeassistant.components.blue_current.config_flow.Client.validate_api_token", + return_value="1234", + ), patch( + "homeassistant.components.blue_current.config_flow.Client.get_email", + return_value="test@email.com", + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_token": "123", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "test@email.com" + assert result2["data"] == {"api_token": "123"} + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (InvalidApiToken(), "invalid_token"), + (RequestLimitReached(), "limit_reached"), + (AlreadyConnected(), "already_connected"), + (Exception(), "unknown"), + (WebsocketError(), "cannot_connect"), + ], +) +async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) -> None: + """Test bluecurrent api errors during configuration flow.""" + with patch( + "homeassistant.components.blue_current.config_flow.Client.validate_api_token", + side_effect=error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123"}, + ) + assert result["errors"]["base"] == message + + with patch( + "homeassistant.components.blue_current.config_flow.Client.validate_api_token", + return_value="1234", + ), patch( + "homeassistant.components.blue_current.config_flow.Client.get_email", + return_value="test@email.com", + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_token": "123", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "test@email.com" + assert result2["data"] == {"api_token": "123"} + + +@pytest.mark.parametrize( + ("customer_id", "reason", "expected_api_token"), + [ + ("1234", "reauth_successful", "1234567890"), + ("6666", "wrong_account", "123"), + ], +) +async def test_reauth( + hass: HomeAssistant, customer_id: str, reason: str, expected_api_token: str +) -> None: + """Test reauth flow.""" + with patch( + "homeassistant.components.blue_current.config_flow.Client.validate_api_token", + return_value=customer_id, + ), patch( + "homeassistant.components.blue_current.config_flow.Client.get_email", + return_value="test@email.com", + ): + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="1234", + data={"api_token": "123"}, + ) + 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, + "unique_id": entry.unique_id, + }, + data={"api_token": "123"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"api_token": "1234567890"}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + assert entry.data == {"api_token": expected_api_token} + + await hass.async_block_till_done() diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py new file mode 100644 index 00000000000000..14bd055cd453a7 --- /dev/null +++ b/tests/components/blue_current/test_init.py @@ -0,0 +1,221 @@ +"""Test Blue Current Init Component.""" + +from datetime import timedelta +from unittest.mock import patch + +from bluecurrent_api.client import Client +from bluecurrent_api.exceptions import ( + BlueCurrentException, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) +import pytest + +from homeassistant.components.blue_current import DOMAIN, Connector, async_setup_entry +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + IntegrationError, +) + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry(hass: HomeAssistant) -> None: + """Test load and unload entry.""" + config_entry = await init_integration(hass, "sensor", {}) + assert config_entry.state == ConfigEntryState.LOADED + assert isinstance(hass.data[DOMAIN][config_entry.entry_id], Connector) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert hass.data[DOMAIN] == {} + + +@pytest.mark.parametrize( + ("api_error", "config_error"), + [ + (InvalidApiToken, ConfigEntryAuthFailed), + (BlueCurrentException, ConfigEntryNotReady), + ], +) +async def test_config_exceptions( + hass: HomeAssistant, api_error: BlueCurrentException, config_error: IntegrationError +) -> None: + """Tests if the correct config error is raised when connecting to the api fails.""" + with patch( + "homeassistant.components.blue_current.Client.connect", + side_effect=api_error, + ), pytest.raises(config_error): + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + config_entry.add_to_hass(hass) + + await async_setup_entry(hass, config_entry) + + +async def test_on_data(hass: HomeAssistant) -> None: + """Test on_data.""" + + await init_integration(hass, "sensor", {}) + + with patch( + "homeassistant.components.blue_current.async_dispatcher_send" + ) as test_async_dispatcher_send: + connector: Connector = hass.data[DOMAIN]["uuid"] + + # test CHARGE_POINTS + data = { + "object": "CHARGE_POINTS", + "data": [{"evse_id": "101", "model_type": "hidden", "name": ""}], + } + await connector.on_data(data) + assert connector.charge_points == {"101": {"model_type": "hidden", "name": ""}} + + # test CH_STATUS + data2 = { + "object": "CH_STATUS", + "data": { + "actual_v1": 12, + "actual_v2": 14, + "actual_v3": 15, + "actual_p1": 12, + "actual_p2": 14, + "actual_p3": 15, + "activity": "charging", + "start_datetime": "2021-11-18T14:12:23", + "stop_datetime": "2021-11-18T14:32:23", + "offline_since": "2021-11-18T14:32:23", + "total_cost": 10.52, + "vehicle_status": "standby", + "actual_kwh": 10, + "evse_id": "101", + }, + } + await connector.on_data(data2) + assert connector.charge_points == { + "101": { + "model_type": "hidden", + "name": "", + "actual_v1": 12, + "actual_v2": 14, + "actual_v3": 15, + "actual_p1": 12, + "actual_p2": 14, + "actual_p3": 15, + "activity": "charging", + "start_datetime": "2021-11-18T14:12:23", + "stop_datetime": "2021-11-18T14:32:23", + "offline_since": "2021-11-18T14:32:23", + "total_cost": 10.52, + "vehicle_status": "standby", + "actual_kwh": 10, + } + } + + test_async_dispatcher_send.assert_called_with( + hass, "blue_current_value_update_101" + ) + + # test GRID_STATUS + data3 = { + "object": "GRID_STATUS", + "data": { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + }, + } + await connector.on_data(data3) + assert connector.grid == { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + } + test_async_dispatcher_send.assert_called_with(hass, "blue_current_grid_update") + + +async def test_start_loop(hass: HomeAssistant) -> None: + """Tests start_loop.""" + + with patch( + "homeassistant.components.blue_current.async_call_later" + ) as test_async_call_later: + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + + connector = Connector(hass, config_entry, Client) + + with patch( + "homeassistant.components.blue_current.Client.start_loop", + side_effect=WebsocketError("unknown command"), + ): + await connector.start_loop() + test_async_call_later.assert_called_with(hass, 1, connector.reconnect) + + with patch( + "homeassistant.components.blue_current.Client.start_loop", + side_effect=RequestLimitReached, + ): + await connector.start_loop() + test_async_call_later.assert_called_with(hass, 1, connector.reconnect) + + +async def test_reconnect(hass: HomeAssistant) -> None: + """Tests reconnect.""" + + with patch( + "homeassistant.components.blue_current.async_call_later" + ) as test_async_call_later: + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + + connector = Connector(hass, config_entry, Client) + + with patch( + "homeassistant.components.blue_current.Client.connect", + side_effect=WebsocketError, + ): + await connector.reconnect() + + test_async_call_later.assert_called_with(hass, 20, connector.reconnect) + + with patch( + "homeassistant.components.blue_current.Client.connect", + side_effect=RequestLimitReached, + ), patch( + "homeassistant.components.blue_current.Client.get_next_reset_delta", + return_value=timedelta(hours=1), + ): + await connector.reconnect() + + test_async_call_later.assert_called_with( + hass, timedelta(hours=1), connector.reconnect + ) + + with patch("homeassistant.components.blue_current.Client.connect"), patch( + "homeassistant.components.blue_current.Connector.start_loop" + ) as test_start_loop, patch( + "homeassistant.components.blue_current.Client.get_charge_points" + ) as test_get_charge_points: + await connector.reconnect() + test_start_loop.assert_called_once() + test_get_charge_points.assert_called_once() diff --git a/tests/components/blue_current/test_sensor.py b/tests/components/blue_current/test_sensor.py new file mode 100644 index 00000000000000..a4bcbfcda00c02 --- /dev/null +++ b/tests/components/blue_current/test_sensor.py @@ -0,0 +1,181 @@ +"""The tests for Blue current sensors.""" +from datetime import datetime +from typing import Any + +from homeassistant.components.blue_current import Connector +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from . import init_integration + +TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") + + +charge_point = { + "actual_v1": 14, + "actual_v2": 18, + "actual_v3": 15, + "actual_p1": 19, + "actual_p2": 14, + "actual_p3": 15, + "activity": "available", + "start_datetime": datetime.strptime("20211118 14:12:23+08:00", "%Y%m%d %H:%M:%S%z"), + "stop_datetime": datetime.strptime("20211118 14:32:23+00:00", "%Y%m%d %H:%M:%S%z"), + "offline_since": datetime.strptime("20211118 14:32:23+00:00", "%Y%m%d %H:%M:%S%z"), + "total_cost": 13.32, + "avg_current": 16, + "avg_voltage": 15.7, + "total_kw": 251.2, + "vehicle_status": "standby", + "actual_kwh": 11, + "max_usage": 10, + "max_offline": 7, + "smartcharging_max_usage": 6, + "current_left": 10, +} + +data: dict[str, Any] = { + "101": { + "model_type": "hidden", + "evse_id": "101", + "name": "", + **charge_point, + } +} + + +charge_point_entity_ids = { + "voltage_phase_1": "actual_v1", + "voltage_phase_2": "actual_v2", + "voltage_phase_3": "actual_v3", + "current_phase_1": "actual_p1", + "current_phase_2": "actual_p2", + "current_phase_3": "actual_p3", + "activity": "activity", + "started_on": "start_datetime", + "stopped_on": "stop_datetime", + "offline_since": "offline_since", + "total_cost": "total_cost", + "average_current": "avg_current", + "average_voltage": "avg_voltage", + "total_power": "total_kw", + "vehicle_status": "vehicle_status", + "energy_usage": "actual_kwh", + "max_usage": "max_usage", + "offline_max_usage": "max_offline", + "smart_charging_max_usage": "smartcharging_max_usage", + "remaining_current": "current_left", +} + +grid = { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + "grid_max_current": 15, + "grid_avg_current": 13.7, +} + +grid_entity_ids = { + "grid_current_phase_1": "grid_actual_p1", + "grid_current_phase_2": "grid_actual_p2", + "grid_current_phase_3": "grid_actual_p3", + "max_grid_current": "grid_max_current", + "average_grid_current": "grid_avg_current", +} + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test the underlying sensors.""" + await init_integration(hass, "sensor", data, grid) + + entity_registry = er.async_get(hass) + for entity_id, key in charge_point_entity_ids.items(): + entry = entity_registry.async_get(f"sensor.101_{entity_id}") + assert entry + assert entry.unique_id == f"{key}_101" + + # skip sensors that are disabled by default. + if not entry.disabled: + state = hass.states.get(f"sensor.101_{entity_id}") + assert state is not None + + value = charge_point[key] + + if key in TIMESTAMP_KEYS: + assert datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") == value + else: + assert state.state == str(value) + + for entity_id, key in grid_entity_ids.items(): + entry = entity_registry.async_get(f"sensor.{entity_id}") + assert entry + assert entry.unique_id == key + + # skip sensors that are disabled by default. + if not entry.disabled: + state = hass.states.get(f"sensor.{entity_id}") + assert state is not None + assert state.state == str(grid[key]) + + sensors = er.async_entries_for_config_entry(entity_registry, "uuid") + assert len(charge_point.keys()) + len(grid.keys()) == len(sensors) + + +async def test_sensor_update(hass: HomeAssistant) -> None: + """Test if the sensors get updated when there is new data.""" + await init_integration(hass, "sensor", data, grid) + key = "avg_voltage" + entity_id = "average_voltage" + timestamp_key = "start_datetime" + timestamp_entity_id = "started_on" + grid_key = "grid_avg_current" + grid_entity_id = "average_grid_current" + + connector: Connector = hass.data["blue_current"]["uuid"] + + connector.charge_points = {"101": {key: 20, timestamp_key: None}} + connector.grid = {grid_key: 20} + async_dispatcher_send(hass, "blue_current_value_update_101") + await hass.async_block_till_done() + async_dispatcher_send(hass, "blue_current_grid_update") + await hass.async_block_till_done() + + # test data updated + state = hass.states.get(f"sensor.101_{entity_id}") + assert state is not None + assert state.state == str(20) + + # grid + state = hass.states.get(f"sensor.{grid_entity_id}") + assert state + assert state.state == str(20) + + # test unavailable + state = hass.states.get("sensor.101_energy_usage") + assert state + assert state.state == "unavailable" + + # test if timestamp keeps old value + state = hass.states.get(f"sensor.101_{timestamp_entity_id}") + assert state + assert ( + datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") + == charge_point[timestamp_key] + ) + + # test if older timestamp is ignored + connector.charge_points = { + "101": { + timestamp_key: datetime.strptime( + "20211118 14:11:23+08:00", "%Y%m%d %H:%M:%S%z" + ) + } + } + async_dispatcher_send(hass, "blue_current_value_update_101") + state = hass.states.get(f"sensor.101_{timestamp_entity_id}") + assert state + assert ( + datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") + == charge_point[timestamp_key] + ) diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index b2d3ce517d89c2..c11a467de9b05d 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 f831445b60c913..b0439896c258b0 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -3,6 +3,7 @@ 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/__init__.py b/tests/components/bluetooth/__init__.py index 55d995dd63c2e8..5ad4b5a6c310c6 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -5,11 +5,12 @@ import itertools import time from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import BaseHaScanner, BluetoothManager, get_manager from homeassistant.components.bluetooth import ( DOMAIN, @@ -17,10 +18,7 @@ BluetoothServiceInfo, BluetoothServiceInfoBleak, async_get_advertisement_callback, - models, ) -from homeassistant.components.bluetooth.base_scanner import BaseHaScanner -from homeassistant.components.bluetooth.manager import BluetoothManager from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -37,6 +35,7 @@ "generate_advertisement_data", "generate_ble_device", "MockBleakClient", + "patch_bluetooth_time", ) ADVERTISEMENT_DATA_DEFAULTS = { @@ -56,6 +55,19 @@ } +@contextmanager +def patch_bluetooth_time(mock_time: float) -> None: + """Patch the bluetooth time.""" + with patch( + "homeassistant.components.bluetooth.MONOTONIC_TIME", return_value=mock_time + ), patch( + "habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time + ), patch( + "habluetooth.manager.monotonic_time_coarse", return_value=mock_time + ), patch("habluetooth.scanner.monotonic_time_coarse", return_value=mock_time): + yield + + def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" new = kwargs.copy() @@ -88,7 +100,7 @@ def generate_ble_device( def _get_manager() -> BluetoothManager: """Return the bluetooth manager.""" - return models.MANAGER + return get_manager() def inject_advertisement( diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 59c5cc822dff5b..4ec6c4e5388164 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", + "habluetooth.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", + "habluetooth.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", + "habluetooth.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", { @@ -122,11 +128,9 @@ def two_adapters_fixture(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.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", @@ -164,11 +168,9 @@ def one_adapter_old_bluez(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.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/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index f04ea2873f01c0..f90b82fc37909e 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -1,8 +1,8 @@ """Tests for the Bluetooth integration advertisement tracking.""" from datetime import timedelta import time -from unittest.mock import patch +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED import pytest from homeassistant.components.bluetooth import ( @@ -10,9 +10,6 @@ async_register_scanner, async_track_unavailable, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.components.bluetooth.const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SOURCE_LOCAL, @@ -27,6 +24,7 @@ generate_ble_device, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed @@ -72,9 +70,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: ) monotonic_now = start_monotonic_time + ((ADVERTISING_TIMES_NEEDED - 1) * 2) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -125,9 +122,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -191,9 +187,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -247,9 +242,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -323,9 +317,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -352,8 +345,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) switchbot_device_went_unavailable = False - scanner = FakeScanner(hass, "new", "fake_adapter") - cancel_scanner = async_register_scanner(hass, scanner, False) + scanner = FakeScanner("new", "fake_adapter") + cancel_scanner = async_register_scanner(hass, scanner) @callback def _switchbot_device_unavailable_callback(_address: str) -> None: @@ -404,9 +397,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -417,9 +409,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: cancel_scanner() # Now that the scanner is gone we should go back to the stack default timeout - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -429,9 +420,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: assert switchbot_device_went_unavailable is False # Now that the scanner is gone we should go back to the stack default timeout - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, @@ -486,9 +476,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: ) monotonic_now = start_monotonic_time + UNAVAILABLE_TRACK_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 63b60c8f487f26..a42752dcfc7190 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -27,8 +27,8 @@ async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test we can get a scanner by source.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") - cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) + hci2_scanner = FakeScanner("hci2", "hci2") + cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner) assert async_scanner_by_source(hass, "hci2") is hci2_scanner cancel_hci2() @@ -40,6 +40,14 @@ async def test_monotonic_time() -> None: assert MONOTONIC_TIME() == pytest.approx(time.monotonic(), abs=0.1) +async def test_async_get_advertisement_callback( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test getting advertisement callback.""" + callback = bluetooth.async_get_advertisement_callback(hass) + assert callback is not None + + async def test_async_scanner_devices_by_address_connectable( hass: HomeAssistant, enable_bluetooth: None ) -> None: @@ -63,15 +71,12 @@ def inject_advertisement( MONOTONIC_TIME(), ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeInjectableScanner( - hass, "esp32", "esp32", new_info_callback, connector, False - ) + scanner = FakeInjectableScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", @@ -135,8 +140,8 @@ def discovered_devices_and_advertisement_data( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeStaticScanner(hass, "esp32", "esp32", connector) - cancel = manager.async_register_scanner(scanner, False) + scanner = FakeStaticScanner("esp32", "esp32", connector) + cancel = manager.async_register_scanner(scanner) assert scanner.discovered_devices_and_advertisement_data == { switchbot_device.address: (switchbot_device, switchbot_device_adv) diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 31d90a6e93d81b..e1d64115e8603c 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -8,6 +8,7 @@ from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth @@ -17,9 +18,6 @@ HaBluetoothConnector, storage, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, -) from homeassistant.components.bluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -37,11 +35,35 @@ _get_manager, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed, load_fixture +class FakeScanner(BaseHaRemoteScanner): + """Fake scanner.""" + + def inject_advertisement( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + now: float | None = None, + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + now or MONOTONIC_TIME(), + ) + + @pytest.mark.parametrize("name_2", [None, "w"]) async def test_remote_scanner( hass: HomeAssistant, enable_bluetooth: None, name_2: str | None @@ -89,30 +111,12 @@ async def test_remote_scanner( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, True) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(switchbot_device, switchbot_device_adv) @@ -173,30 +177,12 @@ async def test_remote_scanner_expires_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, True) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() scanner.inject_advertisement(switchbot_device, switchbot_device_adv) @@ -214,10 +200,7 @@ def inject_advertisement( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -248,30 +231,12 @@ async def test_remote_scanner_expires_non_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() scanner.inject_advertisement(switchbot_device, switchbot_device_adv) @@ -297,10 +262,7 @@ def inject_advertisement( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -313,10 +275,7 @@ def inject_advertisement( expire_utc = dt_util.utcnow() + timedelta( seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -346,30 +305,12 @@ async def test_base_scanner_connecting_behavior( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) with scanner.connecting(): assert scanner.scanning is False @@ -419,15 +360,13 @@ async def test_restore_history_remote_adapter( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) scanner = BaseHaRemoteScanner( - hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", - lambda adv: None, connector, True, ) unsetup = scanner.async_setup() - cancel = _get_manager().async_register_scanner(scanner, True) + cancel = _get_manager().async_register_scanner(scanner) assert "EB:0B:36:35:6F:A4" in scanner.discovered_devices_and_advertisement_data assert "E3:A5:63:3E:5E:23" not in scanner.discovered_devices_and_advertisement_data @@ -435,15 +374,13 @@ async def test_restore_history_remote_adapter( unsetup() scanner = BaseHaRemoteScanner( - hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", - lambda adv: None, connector, True, ) unsetup = scanner.async_setup() - cancel = _get_manager().async_register_scanner(scanner, True) + cancel = _get_manager().async_register_scanner(scanner) assert "EB:0B:36:35:6F:A4" in scanner.discovered_devices_and_advertisement_data assert "E3:A5:63:3E:5E:23" not in scanner.discovered_devices_and_advertisement_data @@ -470,30 +407,12 @@ async def test_device_with_ten_minute_advertising_interval( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) monotonic_now = time.monotonic() new_time = monotonic_now @@ -514,11 +433,8 @@ def _bparasite_device_unavailable_callback(_address: str) -> None: connectable=False, ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement(bparasite_device, bparasite_device_adv, new_time) original_device = scanner.discovered_devices_and_advertisement_data[ bparasite_device.address @@ -527,11 +443,10 @@ def _bparasite_device_unavailable_callback(_address: str) -> None: for _ in range(1, 20): new_time += advertising_interval - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, new_time + ) # Make sure the BLEDevice object gets updated # and not replaced @@ -545,10 +460,7 @@ def _bparasite_device_unavailable_callback(_address: str) -> None: bluetooth.async_address_present(hass, bparasite_device.address, False) is True ) assert bparasite_device_went_unavailable is False - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=new_time, - ): + with patch_bluetooth_time(new_time): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=future_time)) await hass.async_block_till_done() @@ -558,13 +470,7 @@ def _bparasite_device_unavailable_callback(_address: str) -> None: future_time + advertising_interval + TRACKER_BUFFERING_WOBBLE_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ): + with patch_bluetooth_time(missed_advertisement_future_time): # Fire once for the scanner to expire the device async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -592,32 +498,12 @@ async def test_scanner_stops_responding( """Test we mark a scanner are not scanning when it stops responding.""" manager = _get_manager() - class FakeScanner(BaseHaRemoteScanner): - """A fake remote scanner.""" - - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() @@ -628,10 +514,7 @@ def inject_advertisement( + SCANNER_WATCHDOG_INTERVAL.total_seconds() ) # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): + with patch_bluetooth_time(failure_reached_time): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -652,11 +535,10 @@ def inject_advertisement( failure_reached_time += 1 - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(failure_reached_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, failure_reached_time + ) # As soon as we get a detection, we know the scanner is working again assert scanner.scanning is True diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 0e8b2b54f0610b..a8e693c3f991c3 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,6 +3,7 @@ from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import HaScanner from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -25,6 +26,21 @@ from tests.typing import ClientSessionGenerator +class FakeHaScanner(HaScanner): + """Fake HaScanner.""" + + @property + def discovered_devices_and_advertisement_data(self): + """Return the discovered devices and advertisement data.""" + return { + "44:44:33:11:23:45": ( + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), + generate_advertisement_data(local_name="x"), + ) + } + + +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -38,15 +54,8 @@ async def test_diagnostics( # because we cannot import the scanner class directly without it throwing an # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. + with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - generate_advertisement_data(local_name="x"), - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Linux", ), patch( @@ -88,25 +97,25 @@ async def test_diagnostics( "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 2, }, }, "dbus": { @@ -126,63 +135,42 @@ async def test_diagnostics( } }, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5, "hci1": 2}, - "allocations_by_adapter": {"hci0": [], "hci1": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 2, }, }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {}, "timings": {}, }, - "connectable_history": [], "all_history": [], + "connectable_history": [], "scanners": [ { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [ - { - "address": "44:44:33:11:23:45", - "advertisement_data": [ - "x", - {}, - {}, - [], - -127, - -127, - [[]], - ], - "details": None, - "name": "x", - "rssi": -127, - } - ], + "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -216,7 +204,7 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "adapter": "hci1", @@ -243,13 +231,19 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:02", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5, "hci1": 2}, + "allocations_by_adapter": {"hci0": [], "hci1": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_macos( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -269,14 +263,6 @@ async def test_diagnostics_macos( ) with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - switchbot_adv, - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Darwin", ), patch( @@ -297,43 +283,37 @@ async def test_diagnostics_macos( inject_advertisement(hass, switchbot_device, switchbot_adv) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "manager": { - "slot_manager": { - "adapter_slots": {"Core Bluetooth": 5}, - "allocations_by_adapter": {"Core Bluetooth": []}, - "manager": False, - }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "local"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, - "connectable_history": [ + "all_history": [ { "address": "44:44:33:11:23:45", "advertisement": [ @@ -345,11 +325,11 @@ async def test_diagnostics_macos( -127, [[]], ], + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", }, - "connectable": True, "manufacturer_data": { "1": {"__type": "", "repr": "b'\\x01'"} }, @@ -361,7 +341,7 @@ async def test_diagnostics_macos( "time": ANY, } ], - "all_history": [ + "connectable_history": [ { "address": "44:44:33:11:23:45", "advertisement": [ @@ -373,11 +353,11 @@ async def test_diagnostics_macos( -127, [[]], ], + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", }, - "connectable": True, "manufacturer_data": { "1": {"__type": "", "repr": "b'\\x01'"} }, @@ -396,13 +376,8 @@ async def test_diagnostics_macos( { "address": "44:44:33:11:23:45", "advertisement_data": [ - "wohand", - { - "1": { - "__type": "", - "repr": "b'\\x01'", - } - }, + "x", + {}, {}, [], -127, @@ -420,13 +395,19 @@ async def test_diagnostics_macos( "scanning": True, "source": "Core Bluetooth", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", } ], + "slot_manager": { + "adapter_slots": {"Core Bluetooth": 5}, + "allocations_by_adapter": {"Core Bluetooth": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_remote_adapter( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -473,15 +454,12 @@ def inject_advertisement( assert await hass.config_entries.async_setup(entry1.entry_id) await hass.async_block_till_done() - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner( - hass, "esp32", "esp32", new_info_callback, connector, False - ) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv) @@ -497,17 +475,12 @@ def inject_advertisement( "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", - "sw_version": "homeassistant", + "sw_version": ANY, "vendor_id": "cc01", } }, "dbus": {}, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5}, - "allocations_by_adapter": {"hci0": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -521,8 +494,8 @@ def inject_advertisement( } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "esp32"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, @@ -538,7 +511,7 @@ def inject_advertisement( -127, [], ], - "connectable": False, + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", @@ -564,7 +537,7 @@ def inject_advertisement( [], -127, -127, - [[]], + [], ], "connectable": True, "device": { @@ -578,7 +551,7 @@ def inject_advertisement( "rssi": -127, "service_data": {}, "service_uuids": [], - "source": "local", + "source": "esp32", "time": ANY, } ], @@ -596,19 +569,34 @@ def inject_advertisement( }, { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [], + "discovered_devices_and_advertisement_data": [ + { + "address": "44:44:33:11:23:45", + "advertisement_data": [ + "x", + {}, + {}, + [], + -127, + -127, + [[]], + ], + "details": None, + "name": "x", + "rssi": -127, + } + ], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { - "connectable": False, + "connectable": True, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, - "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, "discovered_devices_and_advertisement_data": [ { "address": "44:44:33:11:23:45", @@ -639,11 +627,16 @@ def inject_advertisement( "name": "esp32", "scanning": True, "source": "esp32", - "storage": None, - "type": "FakeScanner", "start_time": ANY, + "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, + "type": "FakeScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5}, + "allocations_by_adapter": {"hci0": []}, + "manager": False, + }, }, } diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 21fade843f54c5..1659b989af031e 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -7,6 +7,8 @@ from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import scanner +from habluetooth.wrappers import HaBleakScannerWrapper import pytest from homeassistant.components import bluetooth @@ -17,7 +19,6 @@ async_process_advertisements, async_rediscover_address, async_track_unavailable, - scanner, ) from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, @@ -35,7 +36,6 @@ SERVICE_DATA_UUID, SERVICE_UUID, ) -from homeassistant.components.bluetooth.wrappers import HaBleakScannerWrapper from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback @@ -107,7 +107,7 @@ def register_detection_callback(self, *args, **kwargs): """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockPassiveBleakScanner, ): assert await async_setup_component( @@ -158,7 +158,7 @@ def register_detection_callback(self, *args, **kwargs): """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( @@ -185,7 +185,7 @@ async def test_setup_and_stop_no_bluetooth( {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} ] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=BleakError, ) as mock_ha_bleak_scanner, patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -206,7 +206,7 @@ async def test_setup_and_stop_broken_bluetooth( """Test we fail gracefully when bluetooth/dbus is broken.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -231,7 +231,7 @@ async def _mock_hang(): await asyncio.sleep(1) with patch.object(scanner, "START_TIMEOUT", 0), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=_mock_hang, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -251,7 +251,7 @@ async def test_setup_and_retry_adapter_not_yet_available( """Test we retry if the adapter is not yet available.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -267,14 +267,14 @@ async def test_setup_and_retry_adapter_not_yet_available( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -286,7 +286,7 @@ async def test_no_race_during_manual_reload_in_retry_state( """Test we can successfully reload when the entry is in a retry state.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -302,7 +302,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -310,7 +310,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -322,7 +322,7 @@ async def test_calling_async_discovered_devices_no_bluetooth( """Test we fail gracefully when asking for discovered devices and there is no blueooth.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=FileNotFoundError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -2815,16 +2815,16 @@ async def test_scanner_count_connectable( hass: HomeAssistant, enable_bluetooth: None ) -> None: """Test getting the connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") - cancel = bluetooth.async_register_scanner(hass, scanner, False) + scanner = FakeScanner("any", "any") + cancel = bluetooth.async_register_scanner(hass, scanner) assert bluetooth.async_scanner_count(hass, connectable=True) == 1 cancel() async def test_scanner_count(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test getting the connectable and non-connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") - cancel = bluetooth.async_register_scanner(hass, scanner, False) + scanner = FakeScanner("any", "any") + cancel = bluetooth.async_register_scanner(hass, scanner) assert bluetooth.async_scanner_count(hass, connectable=False) == 2 cancel() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 6c89074e471917..4726c12f681c59 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -7,10 +7,12 @@ from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory +from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, BaseHaRemoteScanner, BluetoothChange, @@ -19,7 +21,6 @@ BluetoothServiceInfoBleak, HaBluetoothConnector, async_ble_device_from_address, - async_get_advertisement_callback, async_get_fallback_availability_interval, async_get_learned_advertising_interval, async_scanner_count, @@ -31,9 +32,6 @@ SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) -from homeassistant.components.bluetooth.manager import ( - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, -) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -48,6 +46,7 @@ inject_advertisement_with_source, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed, load_fixture @@ -56,8 +55,8 @@ @pytest.fixture def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci0 scanner.""" - hci0_scanner = FakeScanner(hass, "hci0", "hci0") - cancel = bluetooth.async_register_scanner(hass, hci0_scanner, True) + hci0_scanner = FakeScanner("hci0", "hci0") + cancel = bluetooth.async_register_scanner(hass, hci0_scanner) yield cancel() @@ -65,8 +64,8 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: @pytest.fixture def register_hci1_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci1 scanner.""" - hci1_scanner = FakeScanner(hass, "hci1", "hci1") - cancel = bluetooth.async_register_scanner(hass, hci1_scanner, True) + hci1_scanner = FakeScanner("hci1", "hci1") + cancel = bluetooth.async_register_scanner(hass, hci1_scanner) yield cancel() @@ -317,6 +316,89 @@ async def test_switching_adapters_based_on_stale( ) +async def test_switching_adapters_based_on_stale_with_discovered_interval( + hass: HomeAssistant, + enable_bluetooth: None, + register_hci0_scanner: None, + register_hci1_scanner: None, +) -> None: + """Test switching with discovered interval.""" + + address = "44:44:33:11:23:41" + start_time_monotonic = 50.0 + + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci0, + switchbot_adv_poor_signal_hci0, + start_time_monotonic, + "hci0", + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + bluetooth.async_set_fallback_availability_interval(hass, address, 10) + + switchbot_device_poor_signal_hci1 = generate_ble_device( + address, "wohand_poor_signal_hci1" + ) + switchbot_adv_poor_signal_hci1 = generate_advertisement_data( + local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic, + "hci1", + ) + + # Should not switch adapters until the advertisement is stale + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic + 10 + 1, + "hci1", + ) + + # Should not switch yet since we are not within the + # wobble period + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, + "hci1", + ) + # Should switch to hci1 since the previous advertisement is stale + # even though the signal is poor because the device is now + # likely unreachable via hci0 + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci1 + ) + + async def test_restore_history_from_dbus( hass: HomeAssistant, one_adapter: None, disable_new_discovery_flows ) -> None: @@ -561,9 +643,7 @@ async def test_switching_adapters_when_one_goes_away( hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None ) -> None: """Test switching adapters when one goes away.""" - cancel_hci2 = bluetooth.async_register_scanner( - hass, FakeScanner(hass, "hci2", "hci2"), True - ) + cancel_hci2 = bluetooth.async_register_scanner(hass, FakeScanner("hci2", "hci2")) address = "44:44:33:11:23:45" @@ -612,8 +692,8 @@ async def test_switching_adapters_when_one_stop_scanning( hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None ) -> None: """Test switching adapters when stops scanning.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") - cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) + hci2_scanner = FakeScanner("hci2", "hci2") + cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner) address = "44:44:33:11:23:45" @@ -721,21 +801,18 @@ def inject_advertisement( MONOTONIC_TIME(), ) - new_info_callback = async_get_advertisement_callback(hass) connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) connectable_scanner = FakeScanner( - hass, "connectable", "connectable", - new_info_callback, connector, True, ) unsetup_connectable_scanner = connectable_scanner.async_setup() cancel_connectable_scanner = _get_manager().async_register_scanner( - connectable_scanner, True + connectable_scanner ) connectable_scanner.inject_advertisement( switchbot_device_connectable, switchbot_device_adv @@ -750,16 +827,14 @@ def inject_advertisement( ) not_connectable_scanner = FakeScanner( - hass, "not_connectable", "not_connectable", - new_info_callback, connector, False, ) unsetup_not_connectable_scanner = not_connectable_scanner.async_setup() cancel_not_connectable_scanner = _get_manager().async_register_scanner( - not_connectable_scanner, False + not_connectable_scanner ) not_connectable_scanner.inject_advertisement( switchbot_device_non_connectable, switchbot_device_adv @@ -801,16 +876,14 @@ def _unavailable_callback(service_info: BluetoothServiceInfoBleak) -> None: cancel_unavailable() connectable_scanner_2 = FakeScanner( - hass, "connectable", "connectable", - new_info_callback, connector, True, ) unsetup_connectable_scanner_2 = connectable_scanner_2.async_setup() cancel_connectable_scanner_2 = _get_manager().async_register_scanner( - connectable_scanner, True + connectable_scanner ) connectable_scanner_2.inject_advertisement( switchbot_device_connectable, switchbot_device_adv @@ -898,22 +971,20 @@ def clear_all_devices(self) -> None: """Clear all devices.""" self._discovered_device_advertisement_datas.clear() self._discovered_device_timestamps.clear() + self._previous_service_info.clear() - new_info_callback = async_get_advertisement_callback(hass) connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) non_connectable_scanner = FakeScanner( - hass, "connectable", "connectable", - new_info_callback, connector, False, ) unsetup_connectable_scanner = non_connectable_scanner.async_setup() cancel_connectable_scanner = _get_manager().async_register_scanner( - non_connectable_scanner, True + non_connectable_scanner ) with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: non_connectable_scanner.inject_advertisement( @@ -925,7 +996,7 @@ def clear_all_devices(self) -> None: assert mock_config_flow.mock_calls[0][1][0] == "switchbot" assert async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None - assert async_scanner_count(hass, connectable=True) == 1 + assert async_scanner_count(hass, connectable=False) == 1 assert len(callbacks) == 1 assert ( @@ -962,9 +1033,8 @@ def _unavailable_callback(service_info: BluetoothServiceInfoBleak) -> None: return_value=[{"flow_id": "mock_flow_id"}], ) as mock_async_progress_by_init_data_type, patch.object( hass.config_entries.flow, "async_abort" - ) as mock_async_abort, patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + ) as mock_async_abort, patch_bluetooth_time( + monotonic_now + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -1105,9 +1175,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: ) monotonic_now = start_monotonic_time + 2 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -1170,9 +1239,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: # Check that device hasn't expired after a day monotonic_now = start_monotonic_time + 86400 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -1184,9 +1252,8 @@ def _switchbot_device_unavailable_callback(_address: str) -> None: # Try again after it has expired monotonic_now = start_monotonic_time + 604800 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 746f52537cbbb3..9b513ed2197a8d 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -7,6 +7,7 @@ from bleak import BleakError from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from habluetooth.wrappers import HaBleakClientWrapper, HaBleakScannerWrapper import pytest from homeassistant.components.bluetooth import ( @@ -14,10 +15,6 @@ BaseHaScanner, HaBluetoothConnector, ) -from homeassistant.components.bluetooth.wrappers import ( - HaBleakClientWrapper, - HaBleakScannerWrapper, -) from homeassistant.core import HomeAssistant from . import ( @@ -107,11 +104,11 @@ async def async_get_device_by_address(self, address: str) -> BLEDevice | None: return None scanner = FakeScanner( - hass, "00:00:00:00:00:01", "hci0", ) - cancel = manager.async_register_scanner(scanner, True) + scanner.connectable = True + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01" ) @@ -186,14 +183,12 @@ async def async_get_device_by_address(self, address: str) -> BLEDevice | None: MockBleakClient, "esp32_has_connection_slot", lambda: True ) scanner = FakeScanner( - hass, "esp32_has_connection_slot", "esp32_has_connection_slot", - lambda info: None, connector, True, ) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01" ) @@ -296,8 +291,8 @@ async def async_get_device_by_address(self, address: str) -> BLEDevice | None: return None connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: False) - scanner = FakeScanner(hass, "esp32", "esp32", lambda info: None, connector, True) - cancel = manager.async_register_scanner(scanner, True) + scanner = FakeScanner("esp32", "esp32", connector, True) + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_proxy_device_no_connection_slot, switchbot_adv, "esp32" ) @@ -361,8 +356,8 @@ async def async_get_device_by_address(self, address: str) -> BLEDevice | None: return None connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: True) - scanner = FakeScanner(hass, "esp32", "esp32", lambda info: None, connector, True) - cancel = manager.async_register_scanner(scanner, True) + scanner = FakeScanner("esp32", "esp32", connector, True) + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_proxy_device_with_connection_slot, switchbot_adv, "esp32" ) @@ -467,14 +462,12 @@ async def async_get_device_by_address(self, address: str) -> BLEDevice | None: MockBleakClient, "esp32_has_connection_slot", lambda: True ) scanner = FakeScanner( - hass, "esp32_has_connection_slot", "esp32_has_connection_slot", - lambda info: None, connector, True, ) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot ] @@ -581,14 +574,12 @@ async def async_get_device_by_address(self, address: str) -> BLEDevice | None: MockBleakClient, "esp32_has_connection_slot", lambda: True ) scanner = FakeScanner( - hass, "esp32_has_connection_slot", "esp32_has_connection_slot", - lambda info: None, connector, True, ) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot ] diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index 86f0ee4b5defbd..b6e50ebc565aaa 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -22,7 +22,11 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import inject_bluetooth_service_info, patch_all_discovered_devices +from . import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, + patch_bluetooth_time, +) from tests.common import async_fire_time_changed @@ -159,10 +163,9 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices( + [MagicMock(address="44:44:33:11:23:45")] + ): async_fire_time_changed( hass, dt_util.utcnow() @@ -176,9 +179,8 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 2 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 8cc76e01d8c13e..345c4b62b7eda8 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -48,6 +48,7 @@ inject_bluetooth_service_info, inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) from tests.common import ( @@ -471,9 +472,8 @@ def _async_generate_mock_data( assert processor.available is True monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -490,9 +490,8 @@ def _async_generate_mock_data( monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 2 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index bc32a5b302d483..7673acb80dc142 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -14,7 +14,6 @@ SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) -from homeassistant.components.bluetooth.scanner import NEED_RESET_ERRORS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -26,10 +25,19 @@ async_setup_with_one_adapter, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import MockConfigEntry, async_fire_time_changed +# If the adapter is in a stuck state the following errors are raised: +NEED_RESET_ERRORS = [ + "org.bluez.Error.Failed", + "org.bluez.Error.InProgress", + "org.bluez.Error.NotReady", + "not found", +] + async def test_config_entry_can_be_reloaded_when_stop_raises( hass: HomeAssistant, @@ -42,7 +50,7 @@ async def test_config_entry_can_be_reloaded_when_stop_raises( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", side_effect=BleakError, ): await hass.config_entries.async_reload(entry.entry_id) @@ -57,10 +65,8 @@ async def test_dbus_socket_missing_in_container( ) -> None: """Test we handle dbus being missing in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -79,10 +85,8 @@ async def test_dbus_socket_missing( ) -> None: """Test we handle dbus being missing.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -101,10 +105,8 @@ async def test_dbus_broken_pipe_in_container( ) -> None: """Test we handle dbus broken pipe in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -124,10 +126,8 @@ async def test_dbus_broken_pipe( ) -> None: """Test we handle dbus broken pipe.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -148,7 +148,7 @@ async def test_invalid_dbus_message( """Test we handle invalid dbus message.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=InvalidMessageError, ): await async_setup_with_one_adapter(hass) @@ -168,10 +168,10 @@ async def test_adapter_needs_reset_at_start( """Test we cycle the adapter when it needs a restart.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=[BleakError(error), None], ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -216,7 +216,7 @@ def discovered_devices(self): return mock_discovered with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): await async_setup_with_one_adapter(hass) @@ -227,9 +227,8 @@ def discovered_devices(self): mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -237,9 +236,8 @@ def discovered_devices(self): assert called_start == 1 # Fire a callback to reset the timer - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ): _callback( generate_ble_device("44:44:33:11:23:42", "any_name"), @@ -247,9 +245,8 @@ def discovered_devices(self): ) # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -257,9 +254,8 @@ def discovered_devices(self): assert called_start == 1 # We hit the timer, so we restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, ): async_fire_time_changed( hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20) @@ -302,11 +298,10 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -317,9 +312,8 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -327,9 +321,8 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -337,13 +330,12 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -391,11 +383,10 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -406,9 +397,8 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -416,9 +406,8 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -426,13 +415,12 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -442,13 +430,12 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): # We hit the timer again the previous start call failed, make sure # we try again - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -503,16 +490,15 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -554,26 +540,22 @@ def register_detection_callback(self, callback: AdvertisementDataCallback): start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, - ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True - ): + ), patch("habluetooth.util.recover_adapter", return_value=True): await async_setup_with_one_adapter(hass) assert called_start == 1 # Now force a recover adapter 2x for _ in range(2): - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ): @@ -617,7 +599,7 @@ def register_detection_callback(self, *args, **kwargs): """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index 12bdba66d75c9e..0edff02aa0e50b 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -2,17 +2,12 @@ from unittest.mock import patch import bleak -import bleak_retry_connector -import pytest - -from homeassistant.components.bluetooth.usage import ( +from habluetooth.usage import ( install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher, ) -from homeassistant.components.bluetooth.wrappers import ( - HaBleakClientWrapper, - HaBleakScannerWrapper, -) +from habluetooth.wrappers import HaBleakClientWrapper, HaBleakScannerWrapper + from homeassistant.core import HomeAssistant from . import generate_ble_device @@ -57,47 +52,3 @@ async def test_wrapping_bleak_client( instance = bleak.BleakClient(MOCK_BLE_DEVICE) assert not isinstance(instance, HaBleakClientWrapper) - - -async def test_bleak_client_reports_with_address( - hass: HomeAssistant, enable_bluetooth: None, caplog: pytest.LogCaptureFixture -) -> None: - """Test we report when we pass an address to BleakClient.""" - install_multiple_bleak_catcher() - - instance = bleak.BleakClient("00:00:00:00:00:00") - - assert "BleakClient with an address instead of a BLEDevice" in caplog.text - - assert isinstance(instance, HaBleakClientWrapper) - - uninstall_multiple_bleak_catcher() - - caplog.clear() - - instance = bleak.BleakClient("00:00:00:00:00:00") - - assert not isinstance(instance, HaBleakClientWrapper) - assert "BleakClient with an address instead of a BLEDevice" not in caplog.text - - -async def test_bleak_retry_connector_client_reports_with_address( - hass: HomeAssistant, enable_bluetooth: None, caplog: pytest.LogCaptureFixture -) -> None: - """Test we report when we pass an address to BleakClientWithServiceCache.""" - install_multiple_bleak_catcher() - - instance = bleak_retry_connector.BleakClientWithServiceCache("00:00:00:00:00:00") - - assert "BleakClient with an address instead of a BLEDevice" in caplog.text - - assert isinstance(instance, HaBleakClientWrapper) - - uninstall_multiple_bleak_catcher() - - caplog.clear() - - instance = bleak_retry_connector.BleakClientWithServiceCache("00:00:00:00:00:00") - - assert not isinstance(instance, HaBleakClientWrapper) - assert "BleakClient with an address instead of a BLEDevice" not in caplog.text diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index de646f8ef9c178..e3531a574471f6 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -1,46 +1,50 @@ """Tests for the Bluetooth integration.""" from __future__ import annotations -from collections.abc import Callable +from contextlib import contextmanager from unittest.mock import patch import bleak from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from bleak.exc import BleakError +from habluetooth.usage import ( + install_multiple_bleak_catcher, + uninstall_multiple_bleak_catcher, +) import pytest from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BaseHaRemoteScanner, - BluetoothServiceInfoBleak, HaBluetoothConnector, - async_get_advertisement_callback, -) -from homeassistant.components.bluetooth.usage import ( - install_multiple_bleak_catcher, - uninstall_multiple_bleak_catcher, + HomeAssistantBluetoothManager, ) from homeassistant.core import HomeAssistant from . import _get_manager, generate_advertisement_data, generate_ble_device +@contextmanager +def mock_shutdown(manager: HomeAssistantBluetoothManager) -> None: + """Mock shutdown of the HomeAssistantBluetoothManager.""" + manager.shutdown = True + yield + manager.shutdown = False + + class FakeScanner(BaseHaRemoteScanner): """Fake scanner.""" def __init__( self, - hass: HomeAssistant, scanner_id: str, name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], connector: None, connectable: bool, ) -> None: """Initialize the scanner.""" - super().__init__( - hass, scanner_id, name, new_info_callback, connector, connectable - ) + super().__init__(scanner_id, name, connector, connectable) self._details: dict[str, str | HaBluetoothConnector] = {} def __repr__(self) -> str: @@ -132,7 +136,7 @@ def install_bleak_catcher_fixture(): def mock_platform_client_fixture(): """Fixture that mocks the platform client.""" with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): yield @@ -142,7 +146,7 @@ def mock_platform_client_fixture(): def mock_platform_client_that_fails_to_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsToConnect, ): yield @@ -152,7 +156,7 @@ def mock_platform_client_that_fails_to_connect_fixture(): def mock_platform_client_that_raises_on_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientRaisesOnConnect, ): yield @@ -174,13 +178,8 @@ def _generate_scanners_with_fake_devices(hass): ) hci1_device_advs[device.address] = (device, adv_data) - new_info_callback = async_get_advertisement_callback(hass) - scanner_hci0 = FakeScanner( - hass, "00:00:00:00:00:01", "hci0", new_info_callback, None, True - ) - scanner_hci1 = FakeScanner( - hass, "00:00:00:00:00:02", "hci1", new_info_callback, None, True - ) + scanner_hci0 = FakeScanner("00:00:00:00:00:01", "hci0", None, True) + scanner_hci1 = FakeScanner("00:00:00:00:00:02", "hci1", None, True) for device, adv_data in hci0_device_advs.values(): scanner_hci0.inject_advertisement(device, adv_data) @@ -188,8 +187,8 @@ def _generate_scanners_with_fake_devices(hass): for device, adv_data in hci1_device_advs.values(): scanner_hci1.inject_advertisement(device, adv_data) - cancel_hci0 = manager.async_register_scanner(scanner_hci0, True, 2) - cancel_hci1 = manager.async_register_scanner(scanner_hci1, True, 1) + cancel_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2) + cancel_hci1 = manager.async_register_scanner(scanner_hci1, connection_slots=1) return hci0_device_advs, cancel_hci0, cancel_hci1 @@ -331,27 +330,27 @@ async def connect(self, *args, **kwargs): return True with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is False with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is False # After two tries we should switch to hci1 with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is True # ..and we remember that hci1 works as long as the client doesn't change with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is True @@ -360,9 +359,63 @@ async def connect(self, *args, **kwargs): client = bleak.BleakClient(ble_device) with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is False cancel_hci0() cancel_hci1() + + +async def test_passing_subclassed_str_as_address( + hass: HomeAssistant, + two_adapters: None, + enable_bluetooth: None, + install_bleak_catcher, +) -> None: + """Ensure the client wrapper can handle a subclassed str as the address.""" + _, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices(hass) + + class SubclassedStr(str): + pass + + address = SubclassedStr("00:00:00:00:00:01") + client = bleak.BleakClient(address) + + class FakeBleakClient(BaseFakeBleakClient): + """Fake bleak client.""" + + async def connect(self, *args, **kwargs): + """Connect.""" + return True + + with patch( + "habluetooth.wrappers.get_platform_client_backend_type", + return_value=FakeBleakClient, + ): + assert await client.connect() is True + + cancel_hci0() + cancel_hci1() + + +async def test_raise_after_shutdown( + hass: HomeAssistant, + two_adapters: None, + enable_bluetooth: None, + install_bleak_catcher, + mock_platform_client_that_raises_on_connect, +) -> None: + """Ensure the slot gets released on connection exception.""" + manager = _get_manager() + hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices( + hass + ) + # hci0 has 2 slots, hci1 has 1 slot + with mock_shutdown(manager): + ble_device = hci0_device_advs["00:00:00:00:00:01"][0] + client = bleak.BleakClient(ble_device) + with pytest.raises(BleakError, match="shutdown"): + await client.connect() + cancel_hci0() + cancel_hci1() diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 32405d93e6ba79..b3af5bc59b6f24 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 0509409ad0a5fa..11c2b055f6df79 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 aab41bf63392b2..bc02437f5ba1ca 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/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index 2dbe66139b264e..1860ed197209cb 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -8,7 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from . import check_remote_service_call, setup_mocked_integration @@ -92,7 +92,7 @@ async def test_service_call_invalid_input( old_value = hass.states.get(entity_id).state # Test - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( "select", "select_option", @@ -108,7 +108,7 @@ async def test_service_call_invalid_input( [ (MyBMWRemoteServiceError, HomeAssistantError), (MyBMWAPIError, HomeAssistantError), - (ValueError, ValueError), + (ServiceValidationError, ServiceValidationError), ], ) async def test_service_call_fail( diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 6fbcb928b5a3dd..ff1f986583e7e6 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_fan.py b/tests/components/bond/test_fan.py index db1c0fc787d520..e202433c8d6b65 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -26,6 +26,7 @@ SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, FanEntityFeature, + NotValidPresetModeError, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -251,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, diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 92c11028173be1..6b462a02c268ef 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -184,9 +184,7 @@ async def test_old_identifiers_are_removed( "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 @@ -228,9 +226,7 @@ async def test_smart_by_bond_device_suggested_area( "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 @@ -275,9 +271,7 @@ async def test_bridge_device_suggested_area( "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 diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..2fd515b24e579a --- /dev/null +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': 'localhost', + 'mac': '**REDACTED**', + 'pin': '**REDACTED**', + 'use_psk': True, + }), + 'disabled_by': None, + 'domain': 'braviatv', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'very_unique_string', + 'version': 1, + }), + 'device_info': dict({ + 'area': 'POL', + 'cid': 'very_unique_string', + 'generation': '5.2.0', + 'language': 'pol', + 'macAddr': '**REDACTED**', + 'model': 'TV-Model', + 'name': 'BRAVIA', + 'product': 'TV', + 'region': 'XEU', + 'serial': 'serial_number', + }), + }) +# --- diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 1ac1fcd4bea78d..0f1d08792fab92 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -12,14 +12,13 @@ from homeassistant import data_entry_flow from homeassistant.components import ssdp from homeassistant.components.braviatv.const import ( - CONF_CLIENT_ID, CONF_NICKNAME, CONF_USE_PSK, DOMAIN, NICKNAME_PREFIX, ) from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id diff --git a/tests/components/braviatv/test_diagnostics.py b/tests/components/braviatv/test_diagnostics.py new file mode 100644 index 00000000000000..d0974774e7bcd1 --- /dev/null +++ b/tests/components/braviatv/test_diagnostics.py @@ -0,0 +1,72 @@ +"""Test the BraviaTV diagnostics.""" +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.braviatv.const import CONF_USE_PSK, DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +BRAVIA_SYSTEM_INFO = { + "product": "TV", + "region": "XEU", + "language": "pol", + "model": "TV-Model", + "serial": "serial_number", + "macAddr": "AA:BB:CC:DD:EE:FF", + "name": "BRAVIA", + "generation": "5.2.0", + "area": "POL", + "cid": "very_unique_string", +} +INPUTS = [ + { + "uri": "extInput:hdmi?port=1", + "title": "HDMI 1", + "connection": False, + "label": "", + "icon": "meta:hdmi", + } +] + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "localhost", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_USE_PSK: True, + CONF_PIN: "12345qwerty", + }, + unique_id="very_unique_string", + entry_id="3bd2acb0e4f0476d40865546d0d91921", + ) + + config_entry.add_to_hass(hass) + with patch("pybravia.BraviaClient.connect"), patch( + "pybravia.BraviaClient.pair" + ), patch("pybravia.BraviaClient.set_wol_mode"), patch( + "pybravia.BraviaClient.get_system_info", return_value=BRAVIA_SYSTEM_INFO + ), patch("pybravia.BraviaClient.get_power_status", return_value="active"), patch( + "pybravia.BraviaClient.get_external_status", return_value=INPUTS + ), patch("pybravia.BraviaClient.get_volume_info", return_value={}), patch( + "pybravia.BraviaClient.get_playing_info", return_value={} + ), patch("pybravia.BraviaClient.get_app_list", return_value=[]), patch( + "pybravia.BraviaClient.get_content_list_all", return_value=[] + ): + 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/brother/__init__.py b/tests/components/brother/__init__.py index 3176fa7fc28fd1..8e24c2d8058f50 100644 --- a/tests/components/brother/__init__.py +++ b/tests/components/brother/__init__.py @@ -1,16 +1,13 @@ """Tests for Brother Printer integration.""" import json -import sys from unittest.mock import patch +from homeassistant.components.brother.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture -if sys.version_info < (3, 12): - from homeassistant.components.brother.const import DOMAIN - async def init_integration( hass: HomeAssistant, skip_setup: bool = False diff --git a/tests/components/brother/conftest.py b/tests/components/brother/conftest.py index 558b3b8ac3eb8a..9e81cce9d123cb 100644 --- a/tests/components/brother/conftest.py +++ b/tests/components/brother/conftest.py @@ -1,13 +1,9 @@ """Test fixtures for brother.""" from collections.abc import Generator -import sys from unittest.mock import AsyncMock, patch import pytest -if sys.version_info >= (3, 12): - collect_ignore_glob = ["test_*.py"] - @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 42bcb9847f10c5..4bb5732e616d85 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -32,14 +32,12 @@ 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 44d87745b3f101..b7939e4cb5004d 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 dce881f2f7d42d..d82c32463d8209 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -16,7 +16,7 @@ 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/bthome/test_binary_sensor.py b/tests/components/bthome/test_binary_sensor.py index 168988e510faba..c38bec3ba44903 100644 --- a/tests/components/bthome/test_binary_sensor.py +++ b/tests/components/bthome/test_binary_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import logging import time -from unittest.mock import patch import pytest @@ -25,6 +24,7 @@ from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) _LOGGER = logging.getLogger(__name__) @@ -236,10 +236,7 @@ async def test_unavailable(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -290,10 +287,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -344,10 +338,7 @@ async def test_sleepy_device_restores_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index c1f8e26ccb2acb..0b6e7a42cfb47c 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import logging import time -from unittest.mock import patch import pytest @@ -25,6 +24,7 @@ from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) _LOGGER = logging.getLogger(__name__) @@ -1150,10 +1150,7 @@ async def test_unavailable(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -1206,10 +1203,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -1262,10 +1256,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, - ), patch_all_discovered_devices([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() diff --git a/tests/components/buienradar/test_camera.py b/tests/components/buienradar/test_camera.py index 027fde853c1907..f048f8d69a778d 100644 --- a/tests/components/buienradar/test_camera.py +++ b/tests/components/buienradar/test_camera.py @@ -6,8 +6,8 @@ from aiohttp.client_exceptions import ClientResponseError -from homeassistant.components.buienradar.const import CONF_COUNTRY, CONF_DELTA, DOMAIN -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.buienradar.const import CONF_DELTA, DOMAIN +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util @@ -144,7 +144,7 @@ async def test_belgium_country( aioclient_mock.get(radar_map_url(country_code="BE"), text="hello world") data = copy.deepcopy(TEST_CFG_DATA) - data[CONF_COUNTRY] = "BE" + data[CONF_COUNTRY_CODE] = "BE" mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="TEST_ID", data=data) diff --git a/tests/components/caldav/conftest.py b/tests/components/caldav/conftest.py index 1c773d49166696..504103afe13479 100644 --- a/tests/components/caldav/conftest.py +++ b/tests/components/caldav/conftest.py @@ -1,5 +1,4 @@ """Test fixtures for caldav.""" -from collections.abc import Awaitable, Callable from unittest.mock import Mock, patch import pytest @@ -12,7 +11,6 @@ CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -27,6 +25,13 @@ def mock_platforms() -> list[Platform]: 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.""" @@ -57,21 +62,3 @@ def mock_config_entry() -> MockConfigEntry: CONF_VERIFY_SSL: True, }, ) - - -@pytest.fixture(name="setup_integration") -async def mock_setup_integration( - hass: HomeAssistant, - config_entry: MockConfigEntry, - platforms: list[str], -) -> Callable[[], Awaitable[bool]]: - """Fixture to set up the integration.""" - config_entry.add_to_hass(hass) - - async def run() -> bool: - with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): - result = await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - return result - - return run diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 5a648949f0f4ba..df5428121ee882 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -15,6 +15,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator EVENTS = [ @@ -1085,10 +1086,11 @@ async def test_calendar_components(hass: HomeAssistant) -> None: @freeze_time(_local_datetime(17, 30)) async def test_setup_config_entry( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, ) -> None: """Test a calendar entity from a config entry.""" - assert await setup_integration() + config_entry.add_to_hass(hass) + await config_entry.async_setup(hass) state = hass.states.get(TEST_ENTITY) assert state @@ -1118,10 +1120,11 @@ async def test_setup_config_entry( ) async def test_config_entry_supported_components( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, ) -> None: """Test that calendars are only created for VEVENT types when using a config entry.""" - assert await setup_integration() + config_entry.add_to_hass(hass) + await config_entry.async_setup(hass) state = hass.states.get("calendar.calendar_1") assert state diff --git a/tests/components/caldav/test_init.py b/tests/components/caldav/test_init.py index a37815a007c26b..8e832e24d2d3dd 100644 --- a/tests/components/caldav/test_init.py +++ b/tests/components/caldav/test_init.py @@ -1,6 +1,5 @@ """Unit tests for the CalDav integration.""" -from collections.abc import Awaitable, Callable from unittest.mock import patch from caldav.lib.error import AuthorizationError, DAVError @@ -13,17 +12,21 @@ 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, - setup_integration: Callable[[], Awaitable[bool]], 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"): - assert await setup_integration() + await config_entry.async_setup(hass) assert config_entry.state == ConfigEntryState.LOADED @@ -47,8 +50,7 @@ async def test_load_unload( ) async def test_client_failure( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], - config_entry: MockConfigEntry | None, + config_entry: MockConfigEntry, side_effect: Exception, expected_state: ConfigEntryState, expected_flows: list[str], @@ -61,7 +63,8 @@ async def test_client_failure( "homeassistant.components.caldav.config_flow.caldav.DAVClient" ) as mock_client: mock_client.return_value.principal.side_effect = side_effect - assert not await setup_integration() + await config_entry.async_setup(hass) + await hass.async_block_till_done() assert config_entry.state == expected_state diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 16a95d418a847e..6056cac5fa910b 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -1,16 +1,24 @@ """The tests for the webdav todo component.""" -from collections.abc import Awaitable, Callable +from datetime import UTC, date, datetime +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 @@ -33,6 +41,12 @@ 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 @@ -55,6 +69,19 @@ END:VTODO END:VCALENDAR""" +TODO_ALL_FIELDS = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:2 +DTSTAMP:20171125T000000Z +SUMMARY:Cheese +DESCRIPTION:Any kind will do +STATUS:NEEDS-ACTION +DUE:20171126 +END:VTODO +END:VCALENDAR""" + @pytest.fixture def platforms() -> list[Platform]: @@ -62,6 +89,12 @@ def platforms() -> list[Platform]: 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.""" @@ -74,20 +107,56 @@ def mock_supported_components() -> list[str]: return ["VTODO"] -@pytest.fixture(name="calendars") -def mock_calendars(todos: list[str], supported_components: list[str]) -> list[Mock]: - """Fixture to create calendars for the test.""" +@pytest.fixture(name="calendar") +def mock_calendar(supported_components: list[str]) -> Mock: + """Fixture to create the primary calendar for the test.""" calendar = Mock() - items = [ - Todo(None, f"{idx}.ics", item, calendar, str(idx)) - for idx, item in enumerate(todos) - ] - calendar.search = MagicMock(return_value=items) + 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) + + +IGNORE_COMPONENTS = ["BEGIN", "END", "DTSTAMP", "PRODID", "UID", "VERSION"] + + +def compact_ics(ics: str) -> list[str]: + """Pull out parts of the rfc5545 content useful for assertions in tests.""" + return [ + line + for line in ics.split("\n") + if line and not any(filter(line.startswith, IGNORE_COMPONENTS)) + ] + + @pytest.mark.parametrize( ("todos", "expected_state"), [ @@ -115,11 +184,11 @@ def mock_calendars(todos: list[str], supported_components: list[str]) -> list[Mo ) async def test_todo_list_state( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, expected_state: str, ) -> None: """Test a calendar entity from a config entry.""" - assert await setup_integration() + await config_entry.async_setup(hass) state = hass.states.get(TEST_ENTITY) assert state @@ -127,6 +196,7 @@ async def test_todo_list_state( assert state.state == expected_state assert dict(state.attributes) == { "friendly_name": ENTITY_NAME, + "supported_features": SUPPORTED_FEATURES, } @@ -136,11 +206,594 @@ async def test_todo_list_state( ) async def test_supported_components( hass: HomeAssistant, - setup_integration: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, has_entity: bool, ) -> None: """Test a calendar supported components matches VTODO.""" - assert await setup_integration() + 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": date(2023, 11, 18)}, + {**RESULT_ITEM, "due": "2023-11-18"}, + ), + ( + {"due_datetime": "2023-11-18T08:30:00-06:00"}, + { + "status": "NEEDS-ACTION", + "summary": "Cheese", + "due": datetime(2023, 11, 18, 14, 30, 00, tzinfo=UTC), + }, + {**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"}, + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Swiss Cheese", + ], + "1", + { + "uid": "2", + "summary": "Swiss Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, + ), + ( + {"status": "needs_action"}, + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, + ), + ( + {"status": "completed"}, + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:COMPLETED", + "SUMMARY:Cheese", + ], + "0", + { + "uid": "2", + "summary": "Cheese", + "status": "completed", + "description": "Any kind will do", + "due": "2017-11-26", + }, + ), + ( + {"rename": "Swiss Cheese", "status": "needs_action"}, + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Swiss Cheese", + ], + "1", + { + "uid": "2", + "summary": "Swiss Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, + ), + ( + {"due_date": "2023-11-18"}, + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20231118", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2023-11-18", + }, + ), + ( + {"due_datetime": "2023-11-18T08:30:00-06:00"}, + [ + "DESCRIPTION:Any kind will do", + "DUE;TZID=America/Regina:20231118T083000", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2023-11-18T08:30:00-06:00", + }, + ), + ( + {"due_datetime": None}, + [ + "DESCRIPTION:Any kind will do", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + }, + ), + ( + {"description": "Make sure to get Swiss"}, + [ + "DESCRIPTION:Make sure to get Swiss", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "due": "2017-11-26", + "description": "Make sure to get Swiss", + }, + ), + ( + {"description": None}, + ["DUE;VALUE=DATE:20171126", "STATUS:NEEDS-ACTION", "SUMMARY:Cheese"], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "due": "2017-11-26", + }, + ), + ], + ids=[ + "rename", + "status_needs_action", + "status_completed", + "rename_status", + "due_date", + "due_datetime", + "clear_due_date", + "description", + "clear_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_ALL_FIELDS, 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] + assert compact_ics(ics) == expected_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 7d48228193a215..67e8839f7a5179 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 ad83d039d73332..25804287172bc7 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -12,9 +12,14 @@ 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 2a91a375a1351d..0e761f2f437230 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -2,6 +2,7 @@ import asyncio from http import HTTPStatus import io +from types import ModuleType from unittest.mock import AsyncMock, Mock, PropertyMock, mock_open, patch import pytest @@ -26,6 +27,7 @@ from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_turbo_jpeg +from tests.common import import_and_test_deprecated_constant_enum from tests.typing import ClientSessionGenerator, WebSocketGenerator STREAM_SOURCE = "rtsp://127.0.0.1/stream" @@ -56,10 +58,7 @@ async def mock_stream_source_fixture(): with patch( "homeassistant.components.camera.Camera.stream_source", return_value=STREAM_SOURCE, - ) as mock_stream_source, patch( - "homeassistant.components.camera.Camera.supported_features", - return_value=camera.CameraEntityFeature.STREAM, - ): + ) as mock_stream_source: yield mock_stream_source @@ -69,10 +68,7 @@ async def mock_hls_stream_source_fixture(): with patch( "homeassistant.components.camera.Camera.stream_source", return_value=HLS_STREAM_SOURCE, - ) as mock_hls_stream_source, patch( - "homeassistant.components.camera.Camera.supported_features", - return_value=camera.CameraEntityFeature.STREAM, - ): + ) as mock_hls_stream_source: yield mock_hls_stream_source @@ -367,7 +363,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 +386,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 +406,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 @@ -928,19 +928,15 @@ async def test_use_stream_for_stills( return_value=True, ): # First test when the integration does not support stream should fail - resp = await client.get("/api/camera_proxy/camera.demo_camera") + resp = await client.get("/api/camera_proxy/camera.demo_camera_without_stream") await hass.async_block_till_done() mock_stream_source.assert_not_called() assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR # Test when the integration does not provide a stream_source should fail - with patch( - "homeassistant.components.demo.camera.DemoCamera.supported_features", - return_value=camera.SUPPORT_STREAM, - ): - resp = await client.get("/api/camera_proxy/camera.demo_camera") - await hass.async_block_till_done() - mock_stream_source.assert_called_once() - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + resp = await client.get("/api/camera_proxy/camera.demo_camera") + await hass.async_block_till_done() + mock_stream_source.assert_called_once() + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR with patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", @@ -948,9 +944,6 @@ async def test_use_stream_for_stills( ) as mock_stream_source, patch( "homeassistant.components.camera.create_stream" ) as mock_create_stream, patch( - "homeassistant.components.demo.camera.DemoCamera.supported_features", - return_value=camera.SUPPORT_STREAM, - ), patch( "homeassistant.components.demo.camera.DemoCamera.use_stream_for_stills", return_value=True, ): @@ -967,3 +960,56 @@ async def test_use_stream_for_stills( mock_stream.async_get_image.assert_called_once() assert resp.status == HTTPStatus.OK assert await resp.read() == b"stream_keyframe_image" + + +@pytest.mark.parametrize( + "enum", + list(camera.const.StreamType), +) +@pytest.mark.parametrize( + "module", + [camera, camera.const], +) +def test_deprecated_stream_type_constants( + caplog: pytest.LogCaptureFixture, + enum: camera.const.StreamType, + module: ModuleType, +) -> None: + """Test deprecated stream type constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, "STREAM_TYPE_", "2025.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(camera.CameraEntityFeature), +) +def test_deprecated_support_constants( + caplog: pytest.LogCaptureFixture, + entity_feature: camera.CameraEntityFeature, +) -> None: + """Test deprecated support constants.""" + import_and_test_deprecated_constant_enum( + caplog, camera, entity_feature, "SUPPORT_", "2025.1" + ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCamera(camera.Camera): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockCamera() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "MockCamera" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CameraEntityFeature.ON_OFF" in caplog.text + caplog.clear() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py index bbeef35b6f3889..7aa41b98efa424 100644 --- a/tests/components/camera/test_media_source.py +++ b/tests/components/camera/test_media_source.py @@ -22,14 +22,14 @@ async def test_browsing_hls(hass: HomeAssistant, mock_camera_hls) -> None: assert item is not None assert item.title == "Camera" assert len(item.children) == 0 - assert item.not_shown == 2 + assert item.not_shown == 3 # Adding stream enables HLS camera hass.config.components.add("stream") item = await media_source.async_browse_media(hass, "media-source://camera") assert item.not_shown == 0 - assert len(item.children) == 2 + assert len(item.children) == 3 assert item.children[0].media_content_type == FORMAT_CONTENT_TYPE["hls"] @@ -38,10 +38,9 @@ async def test_browsing_mjpeg(hass: HomeAssistant, mock_camera) -> None: item = await media_source.async_browse_media(hass, "media-source://camera") assert item is not None assert item.title == "Camera" - assert len(item.children) == 2 - assert item.not_shown == 0 + assert len(item.children) == 1 + assert item.not_shown == 2 assert item.children[0].media_content_type == "image/jpg" - assert item.children[1].media_content_type == "image/png" async def test_browsing_filter_web_rtc( @@ -52,7 +51,7 @@ async def test_browsing_filter_web_rtc( assert item is not None assert item.title == "Camera" assert len(item.children) == 0 - assert item.not_shown == 2 + assert item.not_shown == 3 async def test_resolving(hass: HomeAssistant, mock_camera_hls) -> None: diff --git a/tests/components/camera/test_significant_change.py b/tests/components/camera/test_significant_change.py new file mode 100644 index 00000000000000..b1e1eb66589fe5 --- /dev/null +++ b/tests/components/camera/test_significant_change.py @@ -0,0 +1,19 @@ +"""Test the Camera significant change platform.""" +from homeassistant.components.camera import STATE_IDLE, STATE_RECORDING +from homeassistant.components.camera.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change() -> None: + """Detect Camera significant changes.""" + attrs = {} + assert not async_check_significant_change( + None, STATE_IDLE, attrs, STATE_IDLE, attrs + ) + assert not async_check_significant_change( + None, STATE_IDLE, attrs, STATE_IDLE, {"dummy": "dummy"} + ) + assert async_check_significant_change( + None, STATE_IDLE, attrs, STATE_RECORDING, attrs + ) diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 2d688489d39c4a..9b5c2d56d4c721 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 3d9feb3e43c125..55e4d8d5c65ac3 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 @@ def create_task(coroutine, name): 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 @@ def create_task(coroutine, name): 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 @@ def create_task(coroutine, name): 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) @@ -2242,7 +2285,6 @@ async def test_cast_platform_play_media_local_media( quick_play_mock.assert_called() app_data = quick_play_mock.call_args[0][2] # No authSig appended - assert ( - app_data["media_id"] - == f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla" - ) + assert app_data[ + "media_id" + ] == f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla" diff --git a/tests/components/ccm15/__init__.py b/tests/components/ccm15/__init__.py new file mode 100644 index 00000000000000..fe6be699c4d314 --- /dev/null +++ b/tests/components/ccm15/__init__.py @@ -0,0 +1 @@ +"""Tests for the Midea ccm15 AC Controller integration.""" diff --git a/tests/components/ccm15/conftest.py b/tests/components/ccm15/conftest.py new file mode 100644 index 00000000000000..910a74fa0bc027 --- /dev/null +++ b/tests/components/ccm15/conftest.py @@ -0,0 +1,41 @@ +"""Common fixtures for the Midea ccm15 AC Controller tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from ccm15 import CCM15DeviceState, CCM15SlaveDevice +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ccm15.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def ccm15_device() -> Generator[AsyncMock, None, None]: + """Mock ccm15 device.""" + ccm15_devices = { + 0: CCM15SlaveDevice(bytes.fromhex("000000b0b8001b")), + 1: CCM15SlaveDevice(bytes.fromhex("00000041c0001a")), + } + device_state = CCM15DeviceState(devices=ccm15_devices) + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.get_status_async", + return_value=device_state, + ): + yield + + +@pytest.fixture +def network_failure_ccm15_device() -> Generator[AsyncMock, None, None]: + """Mock empty set of ccm15 device.""" + device_state = CCM15DeviceState(devices={}) + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.get_status_async", + return_value=device_state, + ): + yield diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..0d4ce32fb8b473 --- /dev/null +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -0,0 +1,351 @@ +# serializer version: 1 +# name: test_climate_state + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'error_code': 0, + 'fan_mode': 'off', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 0', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.midea_0', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate_state.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26, + 'error_code': 0, + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 1', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + 'temperature': 24, + }), + 'context': , + 'entity_id': 'climate.midea_1', + 'last_changed': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_state.4 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.5 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 0', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.midea_0', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_climate_state.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 1', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.midea_1', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/ccm15/snapshots/test_diagnostics.ambr b/tests/components/ccm15/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..c6b2f9c371e00d --- /dev/null +++ b/tests/components/ccm15/snapshots/test_diagnostics.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + '0': dict({ + 'ac_mode': 4, + 'error_code': 0, + 'fan_locked': False, + 'fan_mode': 5, + 'is_ac_mode_locked': False, + 'is_celsius': True, + 'is_remote_locked': False, + 'locked_ac_mode': 0, + 'locked_cool_temperature': 0, + 'locked_heat_temperature': 0, + 'temperature': 27, + 'temperature_setpoint': 23, + }), + '1': dict({ + 'ac_mode': 0, + 'error_code': 0, + 'fan_locked': False, + 'fan_mode': 2, + 'is_ac_mode_locked': False, + 'is_celsius': True, + 'is_remote_locked': False, + 'locked_ac_mode': 0, + 'locked_cool_temperature': 0, + 'locked_heat_temperature': 0, + 'temperature': 26, + 'temperature_setpoint': 24, + }), + }) +# --- diff --git a/tests/components/ccm15/test_climate.py b/tests/components/ccm15/test_climate.py new file mode 100644 index 00000000000000..36a77aa15ab298 --- /dev/null +++ b/tests/components/ccm15/test_climate.py @@ -0,0 +1,130 @@ +"""Unit test for CCM15 coordinator component.""" +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from ccm15 import CCM15DeviceState +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_HIGH, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_climate_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + ccm15_device: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the coordinator.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get("climate.midea_0") == snapshot + assert entity_registry.async_get("climate.midea_1") == snapshot + + assert hass.states.get("climate.midea_0") == snapshot + assert hass.states.get("climate.midea_1") == snapshot + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_TEMPERATURE: 25}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ["climate.midea_0"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ["climate.midea_0"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + # Create an instance of the CCM15DeviceState class + device_state = CCM15DeviceState(devices={}) + with patch( + "ccm15.CCM15Device.CCM15Device.get_status_async", + return_value=device_state, + ): + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entity_registry.async_get("climate.midea_0") == snapshot + assert entity_registry.async_get("climate.midea_1") == snapshot + + assert hass.states.get("climate.midea_0") == snapshot + assert hass.states.get("climate.midea_1") == snapshot diff --git a/tests/components/ccm15/test_config_flow.py b/tests/components/ccm15/test_config_flow.py new file mode 100644 index 00000000000000..9b6314228cce96 --- /dev/null +++ b/tests/components/ccm15/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the Midea ccm15 AC Controller config flow.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +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( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_host( + 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( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert len(mock_setup_entry.mock_calls) == 0 + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +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( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=False + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_unexpected_error(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( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + side_effect=Exception(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_duplicate_host(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle cannot connect error.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/ccm15/test_diagnostics.py b/tests/components/ccm15/test_diagnostics.py new file mode 100644 index 00000000000000..3700faa51ce7a9 --- /dev/null +++ b/tests/components/ccm15/test_diagnostics.py @@ -0,0 +1,37 @@ +"""Test CCM15 diagnostics.""" +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +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, + ccm15_device: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == snapshot diff --git a/tests/components/ccm15/test_init.py b/tests/components/ccm15/test_init.py new file mode 100644 index 00000000000000..b65f170a656a66 --- /dev/null +++ b/tests/components/ccm15/test_init.py @@ -0,0 +1,32 @@ +"""Tests for the ccm15 component.""" +from unittest.mock import AsyncMock + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload(hass: HomeAssistant, ccm15_device: AsyncMock) -> None: + """Test options flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 6c1d593560e8c2..00f8a34fb0c1d2 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -2,6 +2,8 @@ from datetime import timedelta from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -73,8 +75,8 @@ async def test_update_unique_id(hass: HomeAssistant) -> None: assert entry.unique_id == f"{HOST}:{PORT}" -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_unload_config_entry(hass: HomeAssistant) -> None: """Test unloading a config entry.""" assert hass.state is CoreState.running diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 48421f5c41f0ff..18a70fa9ab6877 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -4,6 +4,8 @@ import ssl from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import CoreState, HomeAssistant @@ -15,8 +17,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_async_setup_entry(hass: HomeAssistant) -> None: """Test async_setup_entry.""" assert hass.state is CoreState.running @@ -82,7 +84,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -98,7 +100,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: assert state.attributes.get("is_valid") next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -126,7 +128,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -143,7 +145,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=socket.gaierror, ): @@ -155,7 +157,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -171,7 +173,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=72) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=ssl.SSLError("something bad"), ): @@ -186,7 +188,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=96) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=Exception() ): async_fire_time_changed(hass, utcnow() + timedelta(hours=96)) diff --git a/tests/components/climate/conftest.py b/tests/components/climate/conftest.py new file mode 100644 index 00000000000000..2db96a20a0b8d3 --- /dev/null +++ b/tests/components/climate/conftest.py @@ -0,0 +1,22 @@ +"""Fixtures for Climate 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/climate/test_device_action.py b/tests/components/climate/test_device_action.py index 8ef73ed4e51440..1fc379487ed1be 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -220,7 +220,7 @@ async def test_action( assert set_hvac_mode_calls[0].service == "set_hvac_mode" assert set_hvac_mode_calls[0].data == { "entity_id": entry.entity_id, - "hvac_mode": const.HVAC_MODE_OFF, + "hvac_mode": const.HVACMode.OFF, } assert set_preset_mode_calls[0].domain == DOMAIN assert set_preset_mode_calls[0].service == "set_preset_mode" @@ -287,7 +287,7 @@ async def test_action_legacy( assert set_hvac_mode_calls[0].service == "set_hvac_mode" assert set_hvac_mode_calls[0].data == { "entity_id": entry.entity_id, - "hvac_mode": const.HVAC_MODE_OFF, + "hvac_mode": const.HVACMode.OFF, } diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 897a7316c950d2..8fc82365c23ee5 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -1,19 +1,46 @@ """The tests for the climate component.""" from __future__ import annotations +from enum import Enum +from types import ModuleType from unittest.mock import MagicMock import pytest import voluptuous as vol +from homeassistant.components import climate from homeassistant.components.climate import ( + DOMAIN, SET_TEMPERATURE_SCHEMA, ClimateEntity, HVACMode, ) +from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, + ClimateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from tests.common import async_mock_service +from tests.common import ( + MockConfigEntry, + MockEntity, + MockModule, + MockPlatform, + async_mock_service, + import_and_test_deprecated_constant, + import_and_test_deprecated_constant_enum, + mock_integration, + mock_platform, +) async def test_set_temp_schema_no_req( @@ -50,9 +77,22 @@ async def test_set_temp_schema( assert calls[-1].data == data -class MockClimateEntity(ClimateEntity): +class MockClimateEntity(MockEntity, ClimateEntity): """Mock Climate device to use in tests.""" + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE + ) + _attr_preset_mode = "home" + _attr_preset_modes = ["home", "away"] + _attr_fan_mode = "auto" + _attr_fan_modes = ["auto", "off"] + _attr_swing_mode = "auto" + _attr_swing_modes = ["auto", "off"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode. @@ -75,6 +115,18 @@ def turn_on(self) -> None: def turn_off(self) -> None: """Turn off.""" + def set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode.""" + self._attr_preset_mode = preset_mode + + def set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + self._attr_fan_mode = fan_mode + + def set_swing_mode(self, swing_mode: str) -> None: + """Set swing mode.""" + self._attr_swing_mode = swing_mode + async def test_sync_turn_on(hass: HomeAssistant) -> None: """Test if async turn_on calls sync turn_on.""" @@ -96,3 +148,208 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: await climate.async_turn_off() assert climate.turn_off.called + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(climate.ClimateEntityFeature, "SUPPORT_") + + _create_tuples(climate.HVACMode, "HVAC_MODE_"), +) +@pytest.mark.parametrize( + "module", + [climate, climate.const], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2025.1" + ) + + +@pytest.mark.parametrize( + ("enum", "constant_postfix"), + [ + (climate.HVACAction.OFF, "OFF"), + (climate.HVACAction.HEATING, "HEAT"), + (climate.HVACAction.COOLING, "COOL"), + (climate.HVACAction.DRYING, "DRY"), + (climate.HVACAction.IDLE, "IDLE"), + (climate.HVACAction.FAN, "FAN"), + ], +) +def test_deprecated_current_constants( + caplog: pytest.LogCaptureFixture, + enum: climate.HVACAction, + constant_postfix: str, +) -> None: + """Test deprecated current constants.""" + import_and_test_deprecated_constant( + caplog, + climate.const, + "CURRENT_HVAC_" + constant_postfix, + f"{enum.__class__.__name__}.{enum.name}", + enum, + "2025.1", + ) + + +async def test_preset_mode_validation( + hass: HomeAssistant, config_flow_fixture: None +) -> None: + """Test mode validation for fan, swing and preset.""" + + 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_climate_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test climate platform via config entry.""" + async_add_entities([MockClimateEntity(name="test", entity_id="climate.test")]) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + ), + built_in=False, + ) + mock_platform( + hass, + "test.climate", + MockPlatform(async_setup_entry=async_setup_entry_climate_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() + + state = hass.states.get("climate.test") + assert state.attributes.get(ATTR_PRESET_MODE) == "home" + assert state.attributes.get(ATTR_FAN_MODE) == "auto" + assert state.attributes.get(ATTR_SWING_MODE) == "auto" + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "climate.test", + "preset_mode": "away", + }, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_MODE, + { + "entity_id": "climate.test", + "swing_mode": "off", + }, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + { + "entity_id": "climate.test", + "fan_mode": "off", + }, + blocking=True, + ) + state = hass.states.get("climate.test") + assert state.attributes.get(ATTR_PRESET_MODE) == "away" + assert state.attributes.get(ATTR_FAN_MODE) == "off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" + + with pytest.raises( + ServiceValidationError, + match="The preset_mode invalid is not a valid preset_mode: home, away", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "climate.test", + "preset_mode": "invalid", + }, + blocking=True, + ) + assert ( + str(exc.value) + == "The preset_mode invalid is not a valid preset_mode: home, away" + ) + assert exc.value.translation_key == "not_valid_preset_mode" + + with pytest.raises( + ServiceValidationError, + match="The swing_mode invalid is not a valid swing_mode: auto, off", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_MODE, + { + "entity_id": "climate.test", + "swing_mode": "invalid", + }, + blocking=True, + ) + assert ( + str(exc.value) == "The swing_mode invalid is not a valid swing_mode: auto, off" + ) + assert exc.value.translation_key == "not_valid_swing_mode" + + with pytest.raises( + ServiceValidationError, + match="The fan_mode invalid is not a valid fan_mode: auto, off", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + { + "entity_id": "climate.test", + "fan_mode": "invalid", + }, + blocking=True, + ) + assert str(exc.value) == "The fan_mode invalid is not a valid fan_mode: auto, off" + assert exc.value.translation_key == "not_valid_fan_mode" + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockClimateEntity(ClimateEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockClimateEntity() + assert entity.supported_features_compat is ClimateEntityFeature(1) + assert "MockClimateEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "ClimateEntityFeature.TARGET_TEMPERATURE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is ClimateEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py new file mode 100644 index 00000000000000..6473eca1b883b4 --- /dev/null +++ b/tests/components/climate/test_intent.py @@ -0,0 +1,234 @@ +"""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 (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 + + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 2"}}, + ) + 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/climate/test_significant_change.py b/tests/components/climate/test_significant_change.py new file mode 100644 index 00000000000000..369e5e67004078 --- /dev/null +++ b/tests/components/climate/test_significant_change.py @@ -0,0 +1,129 @@ +"""Test the Climate significant change platform.""" +import pytest + +from homeassistant.components.climate import ( + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) +from homeassistant.components.climate.significant_change import ( + async_check_significant_change, +) +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + METRIC_SYSTEM as METRIC, + US_CUSTOMARY_SYSTEM as IMPERIAL, + UnitSystem, +) + + +async def test_significant_state_change(hass: HomeAssistant) -> None: + """Detect Climate significant state_changes.""" + attrs = {} + assert not async_check_significant_change(hass, "on", attrs, "on", attrs) + assert async_check_significant_change(hass, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("unit_system", "old_attrs", "new_attrs", "expected_result"), + [ + (METRIC, {ATTR_AUX_HEAT: "old_value"}, {ATTR_AUX_HEAT: "old_value"}, False), + (METRIC, {ATTR_AUX_HEAT: "old_value"}, {ATTR_AUX_HEAT: "new_value"}, True), + (METRIC, {ATTR_FAN_MODE: "old_value"}, {ATTR_FAN_MODE: "old_value"}, False), + (METRIC, {ATTR_FAN_MODE: "old_value"}, {ATTR_FAN_MODE: "new_value"}, True), + ( + METRIC, + {ATTR_HVAC_ACTION: "old_value"}, + {ATTR_HVAC_ACTION: "old_value"}, + False, + ), + ( + METRIC, + {ATTR_HVAC_ACTION: "old_value"}, + {ATTR_HVAC_ACTION: "new_value"}, + True, + ), + ( + METRIC, + {ATTR_PRESET_MODE: "old_value"}, + {ATTR_PRESET_MODE: "old_value"}, + False, + ), + ( + METRIC, + {ATTR_PRESET_MODE: "old_value"}, + {ATTR_PRESET_MODE: "new_value"}, + True, + ), + (METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "old_value"}, False), + (METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "new_value"}, True), + # multiple attributes + ( + METRIC, + {ATTR_HVAC_ACTION: "old_value", ATTR_PRESET_MODE: "old_value"}, + {ATTR_HVAC_ACTION: "new_value", ATTR_PRESET_MODE: "old_value"}, + True, + ), + # float attributes + (METRIC, {ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 61}, True), + (METRIC, {ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 60.9}, False), + ( + METRIC, + {ATTR_CURRENT_HUMIDITY: "invalid"}, + {ATTR_CURRENT_HUMIDITY: 60.0}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_HUMIDITY: 60.0}, + {ATTR_CURRENT_HUMIDITY: "invalid"}, + False, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 22.0}, + {ATTR_CURRENT_TEMPERATURE: 22.5}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 22.0}, + {ATTR_CURRENT_TEMPERATURE: 22.4}, + False, + ), + (METRIC, {ATTR_HUMIDITY: 60.0}, {ATTR_HUMIDITY: 61.0}, True), + (METRIC, {ATTR_HUMIDITY: 60.0}, {ATTR_HUMIDITY: 60.9}, False), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 31.0}, {ATTR_TARGET_TEMP_HIGH: 31.5}, True), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 31.0}, {ATTR_TARGET_TEMP_HIGH: 31.4}, False), + (METRIC, {ATTR_TARGET_TEMP_LOW: 8.0}, {ATTR_TARGET_TEMP_LOW: 8.5}, True), + (METRIC, {ATTR_TARGET_TEMP_LOW: 8.0}, {ATTR_TARGET_TEMP_LOW: 8.4}, False), + (METRIC, {ATTR_TEMPERATURE: 22.0}, {ATTR_TEMPERATURE: 22.5}, True), + (METRIC, {ATTR_TEMPERATURE: 22.0}, {ATTR_TEMPERATURE: 22.4}, False), + (IMPERIAL, {ATTR_TEMPERATURE: 70.0}, {ATTR_TEMPERATURE: 71.0}, True), + (IMPERIAL, {ATTR_TEMPERATURE: 70.0}, {ATTR_TEMPERATURE: 70.9}, False), + # insignificant attributes + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + hass: HomeAssistant, + unit_system: UnitSystem, + old_attrs: dict, + new_attrs: dict, + expected_result: bool, +) -> None: + """Detect Climate significant attribute changes.""" + hass.config.units = unit_system + assert ( + async_check_significant_change(hass, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index ea8c09706c5168..22b84f032f6de5 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -1,7 +1,8 @@ """Tests for the cloud component.""" - from unittest.mock import AsyncMock, patch +from hass_nabucasa import Cloud + from homeassistant.components import cloud from homeassistant.components.cloud import const, prefs as cloud_prefs from homeassistant.setup import async_setup_component @@ -14,7 +15,7 @@ async def mock_cloud(hass, config=None): assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, cloud.DOMAIN, {"cloud": config or {}}) - cloud_inst = hass.data["cloud"] + cloud_inst: Cloud = hass.data["cloud"] with patch("hass_nabucasa.Cloud.run_executor", AsyncMock(return_value=None)): await cloud_inst.initialize() diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 221267c59fb0a4..ef8cb037cdbf72 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -1,20 +1,162 @@ """Fixtures for cloud tests.""" -from unittest.mock import patch - +from collections.abc import AsyncGenerator, Callable, Coroutine +from typing import Any +from unittest.mock import DEFAULT, MagicMock, PropertyMock, patch + +from hass_nabucasa import Cloud +from hass_nabucasa.auth import CognitoAuth +from hass_nabucasa.cloudhooks import Cloudhooks +from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED +from hass_nabucasa.google_report_state import GoogleReportState +from hass_nabucasa.iot import CloudIoT +from hass_nabucasa.remote import RemoteUI +from hass_nabucasa.voice import Voice import jwt import pytest -from homeassistant.components.cloud import const, prefs +from homeassistant.components.cloud import CloudClient, const, prefs +from homeassistant.util.dt import utcnow from . import mock_cloud, mock_cloud_prefs +@pytest.fixture(name="cloud") +async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: + """Mock the cloud object. + + See the real hass_nabucasa.Cloud class for how to configure the mock. + """ + with patch( + "homeassistant.components.cloud.Cloud", autospec=True + ) as mock_cloud_class: + mock_cloud = mock_cloud_class.return_value + + # Attributes set in the constructor without parameters. + # We spec the mocks with the real classes + # and set constructor attributes or mock properties as needed. + mock_cloud.google_report_state = MagicMock(spec=GoogleReportState) + mock_cloud.cloudhooks = MagicMock(spec=Cloudhooks) + mock_cloud.remote = MagicMock( + spec=RemoteUI, + certificate=None, + certificate_status=None, + instance_domain=None, + is_connected=False, + ) + mock_cloud.auth = MagicMock(spec=CognitoAuth) + mock_cloud.iot = MagicMock( + spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED + ) + mock_cloud.voice = MagicMock(spec=Voice) + mock_cloud.started = None + + def set_up_mock_cloud( + cloud_client: CloudClient, mode: str, **kwargs: Any + ) -> DEFAULT: + """Set up mock cloud with a mock constructor.""" + + # Attributes set in the constructor with parameters. + cloud_client.cloud = mock_cloud + mock_cloud.client = cloud_client + default_values = DEFAULT_VALUES[mode] + servers = { + f"{name}_server": server + for name, server in DEFAULT_SERVERS[mode].items() + } + mock_cloud.configure_mock(**default_values, **servers) + mock_cloud.configure_mock(**kwargs) + mock_cloud.mode = mode + + # Properties that we mock as attributes from the constructor. + mock_cloud.websession = cloud_client.websession + + return DEFAULT + + mock_cloud_class.side_effect = set_up_mock_cloud + + # Attributes that we mock with default values. + + mock_cloud.id_token = jwt.encode( + { + "email": "hello@home-assistant.io", + "custom:sub-exp": "2018-01-03", + "cognito:username": "abcdefghjkl", + }, + "test", + ) + mock_cloud.access_token = "test_access_token" + mock_cloud.refresh_token = "test_refresh_token" + + # Properties that we keep as properties. + + def mock_is_logged_in() -> bool: + """Mock is logged in.""" + return mock_cloud.id_token is not None + + is_logged_in = PropertyMock(side_effect=mock_is_logged_in) + type(mock_cloud).is_logged_in = is_logged_in + + def mock_claims() -> dict[str, Any]: + """Mock claims.""" + return Cloud._decode_claims(mock_cloud.id_token) + + claims = PropertyMock(side_effect=mock_claims) + type(mock_cloud).claims = claims + + def mock_is_connected() -> bool: + """Return True if we are connected.""" + return mock_cloud.iot.state == STATE_CONNECTED + + is_connected = PropertyMock(side_effect=mock_is_connected) + type(mock_cloud).is_connected = is_connected + + # Properties that we mock as attributes. + mock_cloud.expiration_date = utcnow() + mock_cloud.subscription_expired = False + + # Methods that we mock with a custom side effect. + + async def mock_login(email: str, password: str) -> None: + """Mock login. + + When called, it should call the on_start callback. + """ + on_start_callback = mock_cloud.register_on_start.call_args[0][0] + await on_start_callback() + + mock_cloud.login.side_effect = mock_login + + yield mock_cloud + + +@pytest.fixture(name="set_cloud_prefs") +def set_cloud_prefs_fixture( + cloud: MagicMock, +) -> Callable[[dict[str, Any]], Coroutine[Any, Any, None]]: + """Fixture for cloud component.""" + + async def set_cloud_prefs(prefs_settings: dict[str, Any]) -> None: + """Set cloud prefs.""" + prefs_to_set = cloud.client.prefs.as_dict() + prefs_to_set.pop(prefs.PREF_ALEXA_DEFAULT_EXPOSE) + prefs_to_set.pop(prefs.PREF_GOOGLE_DEFAULT_EXPOSE) + prefs_to_set.update(prefs_settings) + await cloud.client.prefs.async_update(**prefs_to_set) + + return set_cloud_prefs + + @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 +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + @pytest.fixture(autouse=True) def mock_user_data(): """Mock os module.""" diff --git a/tests/components/cloud/test_binary_sensor.py b/tests/components/cloud/test_binary_sensor.py index 7b090bd5eca012..6505be1fe10360 100644 --- a/tests/components/cloud/test_binary_sensor.py +++ b/tests/components/cloud/test_binary_sensor.py @@ -1,44 +1,68 @@ """Tests for the cloud binary sensor.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from hass_nabucasa.const import DISPATCH_REMOTE_CONNECT, DISPATCH_REMOTE_DISCONNECT +import pytest -from homeassistant.components.cloud.const import DISPATCHER_REMOTE_UPDATE from homeassistant.core import HomeAssistant -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component -async def test_remote_connection_sensor(hass: HomeAssistant) -> None: +@pytest.fixture(autouse=True) +def mock_wait_until() -> Generator[None, None, None]: + """Mock WAIT_UNTIL_CHANGE to execute callback immediately.""" + with patch("homeassistant.components.cloud.binary_sensor.WAIT_UNTIL_CHANGE", 0): + yield + + +async def test_remote_connection_sensor( + hass: HomeAssistant, + cloud: MagicMock, + entity_registry: EntityRegistry, +) -> None: """Test the remote connection sensor.""" + entity_id = "binary_sensor.remote_ui" + cloud.remote.certificate = None + assert await async_setup_component(hass, "cloud", {"cloud": {}}) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.remote_ui") is None + assert hass.states.get(entity_id) is None - # Fake connection/discovery - await async_load_platform(hass, "binary_sensor", "cloud", {}, {"cloud": {}}) + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() - # Mock test env - cloud = hass.data["cloud"] = Mock() - cloud.remote.certificate = None + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "unavailable" + + cloud.remote.is_connected = False + cloud.remote.certificate = object() + cloud.client.dispatcher_message(DISPATCH_REMOTE_DISCONNECT) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.remote_ui") + state = hass.states.get(entity_id) assert state is not None - assert state.state == "unavailable" + assert state.state == "off" - with patch("homeassistant.components.cloud.binary_sensor.WAIT_UNTIL_CHANGE", 0): - cloud.remote.is_connected = False - cloud.remote.certificate = object() - async_dispatcher_send(hass, DISPATCHER_REMOTE_UPDATE, {}) - await hass.async_block_till_done() + cloud.remote.is_connected = True + cloud.client.dispatcher_message(DISPATCH_REMOTE_CONNECT) + await hass.async_block_till_done() - state = hass.states.get("binary_sensor.remote_ui") - assert state.state == "off" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" - cloud.remote.is_connected = True - async_dispatcher_send(hass, DISPATCHER_REMOTE_UPDATE, {}) - await hass.async_block_till_done() + # Test that a state is not set if the entity is removed. + entity_registry.async_remove(entity_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None + + cloud.remote.is_connected = False + cloud.client.dispatcher_message(DISPATCH_REMOTE_DISCONNECT) + await hass.async_block_till_done() - state = hass.states.get("binary_sensor.remote_ui") - assert state.state == "on" + assert hass.states.get(entity_id) is None diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 63ec6ad569dc66..0cd605fd755f48 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -7,7 +7,10 @@ import pytest from homeassistant.components.cloud import DOMAIN -from homeassistant.components.cloud.client import CloudClient +from homeassistant.components.cloud.client import ( + VALID_REPAIR_TRANSLATION_KEYS, + CloudClient, +) from homeassistant.components.cloud.const import ( PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, @@ -21,6 +24,7 @@ from homeassistant.const import CONTENT_TYPE_JSON, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueRegistry from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -248,10 +252,12 @@ async def handler(hass, webhook_id, request): 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 +280,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( @@ -377,3 +385,46 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: "version": HA_VERSION, "instance_id": "12345678901234567890", } + + +@pytest.mark.parametrize( + "translation_key", + sorted(VALID_REPAIR_TRANSLATION_KEYS), +) +async def test_async_create_repair_issue_known( + cloud: MagicMock, + mock_cloud_setup: None, + issue_registry: IssueRegistry, + translation_key: str, +) -> None: + """Test create repair issue for known repairs.""" + identifier = f"test_identifier_{translation_key}" + await cloud.client.async_create_repair_issue( + identifier=identifier, + translation_key=translation_key, + placeholders={"custom_domains": "example.com"}, + severity="warning", + ) + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) + assert issue is not None + + +async def test_async_create_repair_issue_unknown( + cloud: MagicMock, + mock_cloud_setup: None, + issue_registry: IssueRegistry, +) -> None: + """Test not creating repair issue for unknown repairs.""" + identifier = "abc123" + with pytest.raises( + ValueError, + match="Invalid translation key unknown_translation_key", + ): + await cloud.client.async_create_repair_issue( + identifier=identifier, + translation_key="unknown_translation_key", + placeholders={"custom_domains": "example.com"}, + severity="error", + ) + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) + assert issue is None diff --git a/tests/components/cloud/test_config_flow.py b/tests/components/cloud/test_config_flow.py new file mode 100644 index 00000000000000..ee4e37276dc01a --- /dev/null +++ b/tests/components/cloud/test_config_flow.py @@ -0,0 +1,40 @@ +"""Test the Home Assistant Cloud config flow.""" +from unittest.mock import patch + +from homeassistant.components.cloud.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test create cloud entry.""" + + with patch( + "homeassistant.components.cloud.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.cloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + assert result["type"] == "create_entry" + assert result["title"] == "Home Assistant Cloud" + assert result["data"] == {} + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_multiple_entries(hass: HomeAssistant) -> None: + """Test creating multiple cloud entries.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index fe60ca971a197c..39bf60570f27e5 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_http_api.py b/tests/components/cloud/test_http_api.py index fc6861f2b49c8a..299306326916ba 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,5 +1,6 @@ """Tests for the HTTP API for the cloud component.""" import asyncio +from copy import deepcopy from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -8,12 +9,12 @@ from hass_nabucasa import thingtalk, voice from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.const import STATE_CONNECTED -from jose import jwt import pytest from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa.entities import LightCapabilities -from homeassistant.components.cloud.const import DOMAIN +from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY +from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.homeassistant import exposed_entities from homeassistant.core import HomeAssistant, State @@ -21,39 +22,87 @@ from homeassistant.setup import async_setup_component from homeassistant.util.location import LocationInfo -from . import mock_cloud, mock_cloud_prefs - from tests.components.google_assistant import MockConfig from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator -SUBSCRIPTION_INFO_URL = "https://api-test.hass.io/payments/subscription_info" +PIPELINE_DATA_LEGACY = { + "items": [ + { + "conversation_engine": "homeassistant", + "conversation_language": "language_1", + "id": "12345", + "language": "language_1", + "name": "Home Assistant Cloud", + "stt_engine": "cloud", + "stt_language": "language_1", + "tts_engine": "cloud", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "12345", +} +PIPELINE_DATA = { + "items": [ + { + "conversation_engine": "homeassistant", + "conversation_language": "language_1", + "id": "12345", + "language": "language_1", + "name": "Home Assistant Cloud", + "stt_engine": "stt.home_assistant_cloud", + "stt_language": "language_1", + "tts_engine": "cloud", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "12345", +} -@pytest.fixture(name="mock_cloud_login") -def mock_cloud_login_fixture(hass, setup_api): - """Mock cloud is logged in.""" - hass.data[DOMAIN].id_token = jwt.encode( +PIPELINE_DATA_OTHER = { + "items": [ { - "email": "hello@home-assistant.io", - "custom:sub-exp": "2018-01-03", - "cognito:username": "abcdefghjkl", + "conversation_engine": "other", + "conversation_language": "language_1", + "id": "12345", + "language": "language_1", + "name": "Home Assistant", + "stt_engine": "stt.other", + "stt_language": "language_1", + "tts_engine": "other", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, }, - "test", - ) + ], + "preferred_item": "12345", +} + +SUBSCRIPTION_INFO_URL = "https://api-test.hass.io/payments/subscription_info" -@pytest.fixture(autouse=True, name="setup_api") -def setup_api_fixture(hass, aioclient_mock): - """Initialize HTTP API.""" - hass.loop.run_until_complete( - mock_cloud( - hass, - { +@pytest.fixture(name="setup_cloud") +async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: + """Fixture that sets up cloud.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { "mode": "development", "cognito_client_id": "cognito_client_id", "user_pool_id": "user_pool_id", "region": "region", + "alexa_server": "alexa-api.nabucasa.com", "relayer_server": "relayer", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, @@ -61,69 +110,94 @@ def setup_api_fixture(hass, aioclient_mock): "filter": {"include_entities": ["light.kitchen", "switch.ac"]} }, }, - ) + }, ) - return mock_cloud_prefs(hass) - - -@pytest.fixture(name="cloud_client") -def cloud_client_fixture(hass, hass_client): - """Fixture that can fetch from the cloud client.""" - with patch("hass_nabucasa.Cloud._write_user_info"): - yield hass.loop.run_until_complete(hass_client()) - - -@pytest.fixture(name="mock_cognito") -def mock_cognito_fixture(): - """Mock warrant.""" - with patch("hass_nabucasa.auth.CognitoAuth._cognito") as mock_cog: - yield mock_cog() + await hass.async_block_till_done() + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() async def test_google_actions_sync( - mock_cognito, mock_cloud_login, cloud_client + setup_cloud: None, + hass_client: ClientSessionGenerator, ) -> None: """Test syncing Google Actions.""" + cloud_client = await hass_client() with patch( "hass_nabucasa.cloud_api.async_google_actions_request_sync", return_value=Mock(status=200), ) as mock_request_sync: req = await cloud_client.post("/api/cloud/google_actions/sync") assert req.status == HTTPStatus.OK - assert len(mock_request_sync.mock_calls) == 1 + assert mock_request_sync.call_count == 1 async def test_google_actions_sync_fails( - mock_cognito, mock_cloud_login, cloud_client + setup_cloud: None, + hass_client: ClientSessionGenerator, ) -> None: """Test syncing Google Actions gone bad.""" + cloud_client = await hass_client() with patch( "hass_nabucasa.cloud_api.async_google_actions_request_sync", return_value=Mock(status=HTTPStatus.INTERNAL_SERVER_ERROR), ) as mock_request_sync: req = await cloud_client.post("/api/cloud/google_actions/sync") assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR - assert len(mock_request_sync.mock_calls) == 1 + assert mock_request_sync.call_count == 1 + + +async def test_login_view_missing_stt_entity( + hass: HomeAssistant, + setup_cloud: None, + entity_registry: er.EntityRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test logging in when the cloud stt entity is missing.""" + # Make sure that the cloud stt entity does not exist. + entity_registry.async_remove("stt.home_assistant_cloud") + await hass.async_block_till_done() + cloud_client = await hass_client() -async def test_login_view(hass: HomeAssistant, cloud_client) -> None: + # We assume the user needs to login again for some reason. + with patch( + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", + ) as create_pipeline_mock: + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert req.status == HTTPStatus.OK + result = await req.json() + assert result == {"success": True, "cloud_pipeline": None} + create_pipeline_mock.assert_not_awaited() + + +@pytest.mark.parametrize("pipeline_data", [PIPELINE_DATA, PIPELINE_DATA_LEGACY]) +async def test_login_view_existing_pipeline( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + pipeline_data: dict[str, Any], +) -> None: """Test logging in when an assist pipeline is available.""" - hass.data["cloud"] = MagicMock(login=AsyncMock()) - await async_setup_component(hass, "stt", {}) - await async_setup_component(hass, "tts", {}) + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": STORAGE_KEY, + "data": deepcopy(pipeline_data), + } + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + cloud_client = await hass_client() with patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_get_pipelines", - return_value=[ - Mock( - conversation_engine="homeassistant", - id="12345", - stt_engine=DOMAIN, - tts_engine=DOMAIN, - ) - ], - ), patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", ) as create_pipeline_mock: req = await cloud_client.post( "/api/cloud/login", json={"email": "my_username", "password": "my_password"} @@ -135,14 +209,28 @@ async def test_login_view(hass: HomeAssistant, cloud_client) -> None: create_pipeline_mock.assert_not_awaited() -async def test_login_view_create_pipeline(hass: HomeAssistant, cloud_client) -> None: - """Test logging in when no assist pipeline is available.""" - hass.data["cloud"] = MagicMock(login=AsyncMock()) - await async_setup_component(hass, "stt", {}) - await async_setup_component(hass, "tts", {}) +async def test_login_view_create_pipeline( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test logging in when no existing cloud assist pipeline is available.""" + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": STORAGE_KEY, + "data": deepcopy(PIPELINE_DATA_OTHER), + } + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + cloud_client = await hass_client() with patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", return_value=AsyncMock(id="12345"), ) as create_pipeline_mock: req = await cloud_client.post( @@ -152,19 +240,36 @@ async def test_login_view_create_pipeline(hass: HomeAssistant, cloud_client) -> assert req.status == HTTPStatus.OK result = await req.json() assert result == {"success": True, "cloud_pipeline": "12345"} - create_pipeline_mock.assert_awaited_once_with(hass, "cloud", "cloud") + create_pipeline_mock.assert_awaited_once_with( + hass, + stt_engine_id="stt.home_assistant_cloud", + tts_engine_id="cloud", + pipeline_name="Home Assistant Cloud", + ) async def test_login_view_create_pipeline_fail( - hass: HomeAssistant, cloud_client + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], ) -> None: """Test logging in when no assist pipeline is available.""" - hass.data["cloud"] = MagicMock(login=AsyncMock()) - await async_setup_component(hass, "stt", {}) - await async_setup_component(hass, "tts", {}) + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": STORAGE_KEY, + "data": deepcopy(PIPELINE_DATA_OTHER), + } + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + cloud_client = await hass_client() with patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", return_value=None, ) as create_pipeline_mock: req = await cloud_client.post( @@ -174,99 +279,161 @@ async def test_login_view_create_pipeline_fail( assert req.status == HTTPStatus.OK result = await req.json() assert result == {"success": True, "cloud_pipeline": None} - create_pipeline_mock.assert_awaited_once_with(hass, "cloud", "cloud") + create_pipeline_mock.assert_awaited_once_with( + hass, + stt_engine_id="stt.home_assistant_cloud", + tts_engine_id="cloud", + pipeline_name="Home Assistant Cloud", + ) -async def test_login_view_random_exception(cloud_client) -> None: - """Try logging in with invalid JSON.""" - with patch("hass_nabucasa.Cloud.login", side_effect=ValueError("Boom")): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) +async def test_login_view_random_exception( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Try logging in with random exception.""" + cloud_client = await hass_client() + cloud.login.side_effect = ValueError("Boom") + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + assert req.status == HTTPStatus.BAD_GATEWAY resp = await req.json() assert resp == {"code": "valueerror", "message": "Unexpected error: Boom"} -async def test_login_view_invalid_json(cloud_client) -> None: +async def test_login_view_invalid_json( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Try logging in with invalid JSON.""" - with patch("hass_nabucasa.auth.CognitoAuth.async_login") as mock_login: - req = await cloud_client.post("/api/cloud/login", data="Not JSON") + cloud_client = await hass_client() + mock_login = cloud.login + + req = await cloud_client.post("/api/cloud/login", data="Not JSON") + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_login.mock_calls) == 0 + assert mock_login.call_count == 0 -async def test_login_view_invalid_schema(cloud_client) -> None: +async def test_login_view_invalid_schema( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Try logging in with invalid schema.""" - with patch("hass_nabucasa.auth.CognitoAuth.async_login") as mock_login: - req = await cloud_client.post("/api/cloud/login", json={"invalid": "schema"}) + cloud_client = await hass_client() + mock_login = cloud.login + + req = await cloud_client.post("/api/cloud/login", json={"invalid": "schema"}) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_login.mock_calls) == 0 + assert mock_login.call_count == 0 -async def test_login_view_request_timeout(cloud_client) -> None: +async def test_login_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test request timeout while trying to log in.""" - with patch( - "hass_nabucasa.auth.CognitoAuth.async_login", side_effect=asyncio.TimeoutError - ): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) + cloud_client = await hass_client() + cloud.login.side_effect = asyncio.TimeoutError + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) assert req.status == HTTPStatus.BAD_GATEWAY -async def test_login_view_invalid_credentials(cloud_client) -> None: +async def test_login_view_invalid_credentials( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test logging in with invalid credentials.""" - with patch( - "hass_nabucasa.auth.CognitoAuth.async_login", side_effect=Unauthenticated - ): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) + cloud_client = await hass_client() + cloud.login.side_effect = Unauthenticated + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) assert req.status == HTTPStatus.UNAUTHORIZED -async def test_login_view_unknown_error(cloud_client) -> None: +async def test_login_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test unknown error while logging in.""" - with patch("hass_nabucasa.auth.CognitoAuth.async_login", side_effect=UnknownError): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) + cloud_client = await hass_client() + cloud.login.side_effect = UnknownError + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) assert req.status == HTTPStatus.BAD_GATEWAY -async def test_logout_view(hass: HomeAssistant, cloud_client) -> None: +async def test_logout_view( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test logging out.""" - cloud = hass.data["cloud"] = MagicMock() - cloud.logout = AsyncMock(return_value=None) + cloud_client = await hass_client() req = await cloud_client.post("/api/cloud/logout") + assert req.status == HTTPStatus.OK data = await req.json() assert data == {"message": "ok"} - assert len(cloud.logout.mock_calls) == 1 + assert cloud.logout.call_count == 1 -async def test_logout_view_request_timeout(hass: HomeAssistant, cloud_client) -> None: +async def test_logout_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test timeout while logging out.""" - cloud = hass.data["cloud"] = MagicMock() + cloud_client = await hass_client() cloud.logout.side_effect = asyncio.TimeoutError + req = await cloud_client.post("/api/cloud/logout") + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_logout_view_unknown_error(hass: HomeAssistant, cloud_client) -> None: +async def test_logout_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test unknown error while logging out.""" - cloud = hass.data["cloud"] = MagicMock() + cloud_client = await hass_client() cloud.logout.side_effect = UnknownError + req = await cloud_client.post("/api/cloud/logout") + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_register_view_no_location(mock_cognito, cloud_client) -> None: +async def test_register_view_no_location( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test register without location.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth with patch( "homeassistant.components.cloud.http_api.async_detect_location_info", return_value=None, @@ -275,17 +442,24 @@ async def test_register_view_no_location(mock_cognito, cloud_client) -> None: "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"}, ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.register.mock_calls) == 1 - call = mock_cognito.register.mock_calls[0] + assert mock_cognito.async_register.call_count == 1 + call = mock_cognito.async_register.mock_calls[0] result_email, result_pass = call.args assert result_email == "hello@bla.com" assert result_pass == "falcon42" assert call.kwargs["client_metadata"] is None -async def test_register_view_with_location(mock_cognito, cloud_client) -> None: +async def test_register_view_with_location( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test register with location.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth with patch( "homeassistant.components.cloud.http_api.async_detect_location_info", return_value=LocationInfo( @@ -308,9 +482,10 @@ async def test_register_view_with_location(mock_cognito, cloud_client) -> None: "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"}, ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.register.mock_calls) == 1 - call = mock_cognito.register.mock_calls[0] + assert mock_cognito.async_register.call_count == 1 + call = mock_cognito.async_register.mock_calls[0] result_email, result_pass = call.args assert result_email == "hello@bla.com" assert result_pass == "falcon42" @@ -321,124 +496,213 @@ async def test_register_view_with_location(mock_cognito, cloud_client) -> None: } -async def test_register_view_bad_data(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_register_view_bad_data( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test register bad data.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/register", json={"email": "hello@bla.com", "not_password": "falcon"} ) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_cognito.logout.mock_calls) == 0 + assert mock_cognito.async_register.call_count == 0 -async def test_register_view_request_timeout(mock_cognito, cloud_client) -> None: - """Test timeout while logging out.""" - mock_cognito.register.side_effect = asyncio.TimeoutError +async def test_register_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test timeout while registering.""" + cloud_client = await hass_client() + cloud.auth.async_register.side_effect = asyncio.TimeoutError + req = await cloud_client.post( "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_register_view_unknown_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.register.side_effect = UnknownError +async def test_register_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test unknown error while registering.""" + cloud_client = await hass_client() + cloud.auth.async_register.side_effect = UnknownError + req = await cloud_client.post( "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_forgot_password_view(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_forgot_password_view( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test forgot password.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 + assert mock_cognito.async_forgot_password.call_count == 1 -async def test_forgot_password_view_bad_data(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_forgot_password_view_bad_data( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test forgot password bad data.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/forgot_password", json={"not_email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0 + assert mock_cognito.async_forgot_password.call_count == 0 -async def test_forgot_password_view_request_timeout(mock_cognito, cloud_client) -> None: - """Test timeout while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError +async def test_forgot_password_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test timeout while forgot password.""" + cloud_client = await hass_client() + cloud.auth.async_forgot_password.side_effect = asyncio.TimeoutError + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_forgot_password_view_unknown_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = UnknownError +async def test_forgot_password_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test unknown error while forgot password.""" + cloud_client = await hass_client() + cloud.auth.async_forgot_password.side_effect = UnknownError + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_forgot_password_view_aiohttp_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = aiohttp.ClientResponseError( +async def test_forgot_password_view_aiohttp_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test unknown error while forgot password.""" + cloud_client = await hass_client() + cloud.auth.async_forgot_password.side_effect = aiohttp.ClientResponseError( Mock(), Mock() ) + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR -async def test_resend_confirm_view(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_resend_confirm_view( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test resend confirm.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 + assert mock_cognito.async_resend_email_confirm.call_count == 1 -async def test_resend_confirm_view_bad_data(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_resend_confirm_view_bad_data( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test resend confirm bad data.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"not_email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0 + assert mock_cognito.async_resend_email_confirm.call_count == 0 -async def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client) -> None: - """Test timeout while logging out.""" - mock_cognito.client.resend_confirmation_code.side_effect = asyncio.TimeoutError +async def test_resend_confirm_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test timeout while resend confirm.""" + cloud_client = await hass_client() + cloud.auth.async_resend_email_confirm.side_effect = asyncio.TimeoutError + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.client.resend_confirmation_code.side_effect = UnknownError +async def test_resend_confirm_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test unknown error while resend confirm.""" + cloud_client = await hass_client() + cloud.auth.async_resend_email_confirm.side_effect = UnknownError + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY async def test_websocket_status( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_cloud_fixture, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test querying the status.""" - hass.data[DOMAIN].iot.state = STATE_CONNECTED + cloud.iot.state = STATE_CONNECTED client = await hass_ws_client(hass) with patch.dict( @@ -452,6 +716,7 @@ async def test_websocket_status( ): await client.send_json({"id": 5, "type": "cloud/status"}) response = await client.receive_json() + assert response["result"] == { "logged_in": True, "email": "hello@home-assistant.io", @@ -462,8 +727,8 @@ async def test_websocket_status( "cloudhooks": {}, "google_enabled": True, "google_secure_devices_pin": None, - "google_default_expose": None, - "alexa_default_expose": None, + "google_default_expose": DEFAULT_EXPOSED_DOMAINS, + "alexa_default_expose": DEFAULT_EXPOSED_DOMAINS, "alexa_report_state": True, "google_report_state": True, "remote_enabled": False, @@ -493,17 +758,23 @@ async def test_websocket_status( "remote_certificate_status": None, "remote_certificate": None, "http_use_ssl": False, - "active_subscription": False, + "active_subscription": True, } async def test_websocket_status_not_logged_in( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status.""" + """Test querying the status not logged in.""" + cloud.id_token = None client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "cloud/status"}) response = await client.receive_json() + assert response["result"] == { "logged_in": False, "cloud": "disconnected", @@ -515,30 +786,32 @@ async def test_websocket_subscription_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aioclient_mock: AiohttpClientMocker, - mock_auth, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status and connecting because valid account.""" + """Test subscription info and connecting because valid account.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) client = await hass_ws_client(hass) + mock_renew = cloud.auth.async_renew_access_token + + await client.send_json({"id": 5, "type": "cloud/subscription"}) + response = await client.receive_json() - with patch("hass_nabucasa.auth.CognitoAuth.async_renew_access_token") as mock_renew: - await client.send_json({"id": 5, "type": "cloud/subscription"}) - response = await client.receive_json() assert response["result"] == {"provider": "stripe"} - assert len(mock_renew.mock_calls) == 1 + assert mock_renew.call_count == 1 async def test_websocket_subscription_fail( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aioclient_mock: AiohttpClientMocker, - mock_auth, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status.""" + """Test subscription info fail.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, status=HTTPStatus.INTERNAL_SERVER_ERROR) client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "cloud/subscription"}) response = await client.receive_json() @@ -547,10 +820,15 @@ async def test_websocket_subscription_fail( async def test_websocket_subscription_not_logged_in( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status.""" + """Test subscription info not logged in.""" + cloud.id_token = None client = await hass_ws_client(hass) + with patch( "hass_nabucasa.cloud_api.async_subscription_info", return_value={"return": "value"}, @@ -565,15 +843,16 @@ async def test_websocket_subscription_not_logged_in( async def test_websocket_update_preferences( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test updating preference.""" - assert setup_api.google_enabled - assert setup_api.alexa_enabled - assert setup_api.google_secure_devices_pin is None + assert cloud.client.prefs.google_enabled + assert cloud.client.prefs.alexa_enabled + assert cloud.client.prefs.google_secure_devices_pin is None + client = await hass_ws_client(hass) + await client.send_json( { "id": 5, @@ -587,18 +866,16 @@ async def test_websocket_update_preferences( response = await client.receive_json() assert response["success"] - assert not setup_api.google_enabled - assert not setup_api.alexa_enabled - assert setup_api.google_secure_devices_pin == "1234" - assert setup_api.tts_default_voice == ("en-GB", "male") + assert not cloud.client.prefs.google_enabled + assert not cloud.client.prefs.alexa_enabled + assert cloud.client.prefs.google_secure_devices_pin == "1234" + assert cloud.client.prefs.tts_default_voice == ("en-GB", "male") async def test_websocket_update_preferences_alexa_report_state( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test updating alexa_report_state sets alexa authorized.""" client = await hass_ws_client(hass) @@ -612,10 +889,12 @@ async def test_websocket_update_preferences_alexa_report_state( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" ) as set_authorized_mock: set_authorized_mock.assert_not_called() + await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(True) assert response["success"] @@ -624,9 +903,7 @@ async def test_websocket_update_preferences_alexa_report_state( async def test_websocket_update_preferences_require_relink( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test updating preference requires relink.""" client = await hass_ws_client(hass) @@ -641,10 +918,12 @@ async def test_websocket_update_preferences_require_relink( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" ) as set_authorized_mock: set_authorized_mock.assert_not_called() + await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(False) assert not response["success"] @@ -654,9 +933,7 @@ async def test_websocket_update_preferences_require_relink( async def test_websocket_update_preferences_no_token( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test updating preference no token available.""" client = await hass_ws_client(hass) @@ -671,10 +948,12 @@ async def test_websocket_update_preferences_no_token( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" ) as set_authorized_mock: set_authorized_mock.assert_not_called() + await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(False) assert not response["success"] @@ -682,69 +961,79 @@ async def test_websocket_update_preferences_no_token( async def test_enabling_webhook( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test we call right code to enable webhooks.""" client = await hass_ws_client(hass) - with patch( - "hass_nabucasa.cloudhooks.Cloudhooks.async_create", return_value={} - ) as mock_enable: - await client.send_json( - {"id": 5, "type": "cloud/cloudhook/create", "webhook_id": "mock-webhook-id"} - ) - response = await client.receive_json() - assert response["success"] + mock_enable = cloud.cloudhooks.async_create + mock_enable.return_value = {} - assert len(mock_enable.mock_calls) == 1 + await client.send_json( + {"id": 5, "type": "cloud/cloudhook/create", "webhook_id": "mock-webhook-id"} + ) + response = await client.receive_json() + + assert response["success"] + assert mock_enable.call_count == 1 assert mock_enable.mock_calls[0][1][0] == "mock-webhook-id" async def test_disabling_webhook( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test we call right code to disable webhooks.""" client = await hass_ws_client(hass) - with patch("hass_nabucasa.cloudhooks.Cloudhooks.async_delete") as mock_disable: - await client.send_json( - {"id": 5, "type": "cloud/cloudhook/delete", "webhook_id": "mock-webhook-id"} - ) - response = await client.receive_json() - assert response["success"] + mock_disable = cloud.cloudhooks.async_delete - assert len(mock_disable.mock_calls) == 1 + await client.send_json( + {"id": 5, "type": "cloud/cloudhook/delete", "webhook_id": "mock-webhook-id"} + ) + response = await client.receive_json() + + assert response["success"] + assert mock_disable.call_count == 1 assert mock_disable.mock_calls[0][1][0] == "mock-webhook-id" async def test_enabling_remote( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test we call right code to enable remote UI.""" client = await hass_ws_client(hass) - cloud = hass.data[DOMAIN] + mock_connect = cloud.remote.connect + assert not cloud.client.remote_autostart + + await client.send_json({"id": 5, "type": "cloud/remote/connect"}) + response = await client.receive_json() - with patch("hass_nabucasa.remote.RemoteUI.connect") as mock_connect: - await client.send_json({"id": 5, "type": "cloud/remote/connect"}) - response = await client.receive_json() assert response["success"] assert cloud.client.remote_autostart + assert mock_connect.call_count == 1 - assert len(mock_connect.mock_calls) == 1 + mock_disconnect = cloud.remote.disconnect + + await client.send_json({"id": 6, "type": "cloud/remote/disconnect"}) + response = await client.receive_json() - with patch("hass_nabucasa.remote.RemoteUI.disconnect") as mock_disconnect: - await client.send_json({"id": 6, "type": "cloud/remote/disconnect"}) - response = await client.receive_json() assert response["success"] assert not cloud.client.remote_autostart - - assert len(mock_disconnect.mock_calls) == 1 + assert mock_disconnect.call_count == 1 async def test_list_google_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can list Google entities.""" client = await hass_ws_client(hass) @@ -762,6 +1051,7 @@ async def test_list_google_entities( ): await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 2 assert response["result"][0] == { @@ -789,6 +1079,7 @@ async def test_list_google_entities( ): await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 2 assert response["result"][0] == { @@ -807,8 +1098,7 @@ async def test_get_google_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can get a Google entity.""" client = await hass_ws_client(hass) @@ -818,6 +1108,7 @@ async def test_get_google_entity( {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_found", @@ -829,10 +1120,12 @@ async def test_get_google_entity( "group", "test", "unique", suggested_object_id="all_locks" ) hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( {"type": "cloud/google_assistant/entities/get", "entity_id": "group.all_locks"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -849,6 +1142,7 @@ async def test_get_google_entity( {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert response["success"] assert response["result"] == { "disable_2fa": None, @@ -861,6 +1155,7 @@ async def test_get_google_entity( {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} ) response = await client.receive_json() + assert response["success"] assert response["result"] == { "disable_2fa": None, @@ -878,12 +1173,14 @@ async def test_get_google_entity( } ) response = await client.receive_json() + assert response["success"] await client.send_json_auto_id( {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} ) response = await client.receive_json() + assert response["success"] assert response["result"] == { "disable_2fa": True, @@ -895,13 +1192,12 @@ async def test_get_google_entity( async def test_update_google_entity( hass: HomeAssistant, - entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can update config of a Google entity.""" client = await hass_ws_client(hass) + await client.send_json_auto_id( { "type": "cloud/google_assistant/entities/update", @@ -910,6 +1206,7 @@ async def test_update_google_entity( } ) response = await client.receive_json() + assert response["success"] await client.send_json_auto_id( @@ -921,8 +1218,8 @@ async def test_update_google_entity( } ) response = await client.receive_json() - assert response["success"] + assert response["success"] assert exposed_entities.async_get_entity_settings(hass, "light.kitchen") == { "cloud.google_assistant": {"disable_2fa": False, "should_expose": False} } @@ -932,8 +1229,7 @@ async def test_list_alexa_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can list Alexa entities.""" client = await hass_ws_client(hass) @@ -946,6 +1242,7 @@ async def test_list_alexa_entities( ): await client.send_json_auto_id({"id": 5, "type": "cloud/alexa/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 1 assert response["result"][0] == { @@ -954,10 +1251,19 @@ async def test_list_alexa_entities( "interfaces": ["Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"], } - # Add the entity to the entity registry - entity_registry.async_get_or_create( - "light", "test", "unique", suggested_object_id="kitchen" - ) + with patch( + ( + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" + ".async_get_access_token" + ), + ), patch( + "homeassistant.components.cloud.alexa_config.alexa_state_report.async_send_add_or_update_message" + ): + # Add the entity to the entity registry + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + await hass.async_block_till_done() with patch( "homeassistant.components.alexa.entities.async_get_entities", @@ -965,6 +1271,7 @@ async def test_list_alexa_entities( ): await client.send_json_auto_id({"type": "cloud/alexa/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 1 assert response["result"][0] == { @@ -978,8 +1285,7 @@ async def test_get_alexa_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can get an Alexa entity.""" client = await hass_ws_client(hass) @@ -989,6 +1295,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert response["success"] assert response["result"] is None @@ -997,6 +1304,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "sensor.temperature"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -1008,10 +1316,12 @@ async def test_get_alexa_entity( "group", "test", "unique", suggested_object_id="all_locks" ) hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( {"type": "cloud/alexa/entities/get", "entity_id": "group.all_locks"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -1029,6 +1339,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert response["success"] assert response["result"] is None @@ -1036,6 +1347,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "water_heater.basement"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -1047,14 +1359,14 @@ async def test_update_alexa_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can update config of an Alexa entity.""" entry = entity_registry.async_get_or_create( "light", "test", "unique", suggested_object_id="kitchen" ) client = await hass_ws_client(hass) + await client.send_json_auto_id( { "type": "homeassistant/expose_entity", @@ -1072,10 +1384,13 @@ async def test_update_alexa_entity( async def test_sync_alexa_entities_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that timeout syncing Alexa entities.""" client = await hass_ws_client(hass) + with patch( ( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" @@ -1091,10 +1406,13 @@ async def test_sync_alexa_entities_timeout( async def test_sync_alexa_entities_no_token( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test sync Alexa entities when we have no token.""" client = await hass_ws_client(hass) + with patch( ( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" @@ -1110,10 +1428,13 @@ async def test_sync_alexa_entities_no_token( async def test_enable_alexa_state_report_fail( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test enable Alexa entities state reporting when no token available.""" client = await hass_ws_client(hass) + with patch( ( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" @@ -1129,7 +1450,9 @@ async def test_enable_alexa_state_report_fail( async def test_thingtalk_convert( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can convert a query.""" client = await hass_ws_client(hass) @@ -1148,7 +1471,9 @@ async def test_thingtalk_convert( async def test_thingtalk_convert_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can convert a query.""" client = await hass_ws_client(hass) @@ -1167,7 +1492,9 @@ async def test_thingtalk_convert_timeout( async def test_thingtalk_convert_internal( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can convert a query.""" client = await hass_ws_client(hass) @@ -1187,7 +1514,9 @@ async def test_thingtalk_convert_internal( async def test_tts_info( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can get TTS info.""" # Verify the format is as expected @@ -1223,6 +1552,7 @@ async def test_tts_info( ) async def test_api_calls_require_admin( hass: HomeAssistant, + setup_cloud: None, hass_client: ClientSessionGenerator, hass_read_only_access_token: str, endpoint: str, diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index f83de408bcced6..0e662c30ee77f5 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -21,10 +21,9 @@ async def test_do_not_create_repair_issues_at_startup_if_not_logged_in( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, ) -> None: """Test that we create repair issue at startup if we are logged in.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) - with patch("homeassistant.components.cloud.Cloud.is_logged_in", False): await mock_cloud(hass) @@ -40,9 +39,9 @@ async def test_create_repair_issues_at_startup_if_logged_in( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_auth: Generator[None, AsyncMock, None], + issue_registry: ir.IssueRegistry, ): """Test that we create repair issue at startup if we are logged in.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) aioclient_mock.get( "https://accounts.nabucasa.com/payments/subscription_info", json={"provider": "legacy"}, @@ -61,9 +60,9 @@ async def test_create_repair_issues_at_startup_if_logged_in( async def test_legacy_subscription_delete_issue_if_no_longer_legacy( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, ) -> None: """Test that we delete the legacy subscription issue if no longer legacy.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"}) assert issue_registry.async_get_issue( domain="cloud", issue_id="legacy_subscription" @@ -80,9 +79,9 @@ async def test_legacy_subscription_repair_flow( aioclient_mock: AiohttpClientMocker, mock_auth: Generator[None, AsyncMock, None], hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, ): """Test desired flow of the fix flow for legacy subscription.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) aioclient_mock.get( "https://accounts.nabucasa.com/payments/subscription_info", json={"provider": None}, @@ -154,6 +153,7 @@ async def test_legacy_subscription_repair_flow( "handler": DOMAIN, "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue( @@ -166,6 +166,7 @@ async def test_legacy_subscription_repair_flow_timeout( hass_client: ClientSessionGenerator, mock_auth: Generator[None, AsyncMock, None], aioclient_mock: AiohttpClientMocker, + issue_registry: ir.IssueRegistry, ): """Test timeout flow of the fix flow for legacy subscription.""" aioclient_mock.post( @@ -173,8 +174,6 @@ async def test_legacy_subscription_repair_flow_timeout( status=403, ) - issue_registry: ir.IssueRegistry = ir.async_get(hass) - cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"}) repair_issue = issue_registry.async_get_issue( domain="cloud", issue_id="legacy_subscription" diff --git a/tests/components/cloud/test_stt.py b/tests/components/cloud/test_stt.py new file mode 100644 index 00000000000000..666d8ae7d65b07 --- /dev/null +++ b/tests/components/cloud/test_stt.py @@ -0,0 +1,201 @@ +"""Test the speech-to-text platform for the cloud integration.""" +from collections.abc import AsyncGenerator +from copy import deepcopy +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from hass_nabucasa.voice import STTResponse, VoiceError +import pytest + +from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY +from homeassistant.components.cloud import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator + +PIPELINE_DATA = { + "items": [ + { + "conversation_engine": "conversation_engine_1", + "conversation_language": "language_1", + "id": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + "language": "language_1", + "name": "Home Assistant Cloud", + "stt_engine": "cloud", + "stt_language": "language_1", + "tts_engine": "cloud", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + { + "conversation_engine": "conversation_engine_2", + "conversation_language": "language_2", + "id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "stt_language": "language_2", + "tts_engine": "tts_engine_2", + "tts_language": "language_2", + "tts_voice": "The Voice", + "wake_word_entity": None, + "wake_word_id": None, + }, + { + "conversation_engine": "conversation_engine_3", + "conversation_language": "language_3", + "id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J", + "language": "language_3", + "name": "name_3", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", +} + + +@pytest.fixture(autouse=True) +async def load_homeassistant(hass: HomeAssistant) -> None: + """Load the homeassistant integration.""" + assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture(autouse=True) +async def delay_save_fixture() -> AsyncGenerator[None, None]: + """Load the homeassistant integration.""" + with patch("homeassistant.helpers.collection.SAVE_DELAY", new=0): + yield + + +@pytest.mark.parametrize( + ("mock_process_stt", "expected_response_data"), + [ + ( + AsyncMock(return_value=STTResponse(True, "Turn the Kitchen Lights on")), + {"text": "Turn the Kitchen Lights on", "result": "success"}, + ), + (AsyncMock(side_effect=VoiceError("Boom!")), {"text": None, "result": "error"}), + ], +) +async def test_cloud_speech( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + mock_process_stt: AsyncMock, + expected_response_data: dict[str, Any], +) -> None: + """Test cloud text-to-speech.""" + cloud.voice.process_stt = mock_process_stt + + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + + state = hass.states.get("stt.home_assistant_cloud") + assert state + assert state.state == STATE_UNKNOWN + + client = await hass_client() + + response = await client.post( + "/api/stt/stt.home_assistant_cloud", + headers={ + "X-Speech-Content": ( + "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=1;" + " language=de-DE" + ) + }, + data=b"Test", + ) + response_data = await response.json() + + assert mock_process_stt.call_count == 1 + assert ( + mock_process_stt.call_args.kwargs["content_type"] + == "audio/wav; codecs=audio/pcm; samplerate=16000" + ) + assert mock_process_stt.call_args.kwargs["language"] == "de-DE" + assert response.status == HTTPStatus.OK + assert response_data == expected_response_data + + state = hass.states.get("stt.home_assistant_cloud") + assert state + assert state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + + +async def test_migrating_pipelines( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test migrating pipelines when cloud stt entity is added.""" + cloud.voice.process_stt = AsyncMock( + return_value=STTResponse(True, "Turn the Kitchen Lights on") + ) + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": "assist_pipeline.pipelines", + "data": deepcopy(PIPELINE_DATA), + } + + assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + await hass.async_block_till_done() + + state = hass.states.get("stt.home_assistant_cloud") + assert state + assert state.state == STATE_UNKNOWN + + # The stt engine should be updated to the new cloud stt engine id. + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_engine"] + == "stt.home_assistant_cloud" + ) + + # The other items should stay the same. + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["conversation_engine"] + == "conversation_engine_1" + ) + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["conversation_language"] + == "language_1" + ) + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["id"] + == "01GX8ZWBAQYWNB1XV3EXEZ75DY" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["language"] == "language_1" + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["name"] == "Home Assistant Cloud" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_language"] == "language_1" + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_engine"] == "cloud" + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_language"] == "language_1" + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_voice"] + == "Arnold Schwarzenegger" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["wake_word_entity"] is None + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["wake_word_id"] is None + assert hass_storage[STORAGE_KEY]["data"]["items"][1] == PIPELINE_DATA["items"][1] + assert hass_storage[STORAGE_KEY]["data"]["items"][2] == PIPELINE_DATA["items"][2] diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index c540394b937fa5..9f1af8aaeb44d2 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -1,20 +1,25 @@ """Test cloud system health.""" import asyncio -from unittest.mock import Mock +from collections.abc import Callable, Coroutine +from typing import Any +from unittest.mock import MagicMock from aiohttp import ClientError from hass_nabucasa.remote import CertificateStatus +from homeassistant.components.cloud import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from tests.common import get_system_health_info from tests.test_util.aiohttp import AiohttpClientMocker async def test_cloud_system_health( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], ) -> None: """Test cloud system health.""" aioclient_mock.get("https://cloud.bla.com/status", text="") @@ -23,32 +28,27 @@ async def test_cloud_system_health( "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", exc=ClientError, ) - hass.config.components.add("cloud") assert await async_setup_component(hass, "system_health", {}) - now = utcnow() + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "user_pool_id": "AAAA", + "region": "us-east-1", + "acme_server": "cert-server", + "relayer_server": "cloud.bla.com", + }, + }, + ) + await hass.async_block_till_done() + + cloud.remote.snitun_server = "us-west-1" + cloud.remote.certificate_status = CertificateStatus.READY - hass.data["cloud"] = Mock( - region="us-east-1", - user_pool_id="AAAA", - relayer_server="cloud.bla.com", - acme_server="cert-server", - is_logged_in=True, - remote=Mock( - is_connected=False, - snitun_server="us-west-1", - certificate_status=CertificateStatus.READY, - ), - expiration_date=now, - is_connected=True, - client=Mock( - relayer_region="xx-earth-616", - prefs=Mock( - remote_enabled=True, - alexa_enabled=True, - google_enabled=False, - instance_id="12345678901234567890", - ), - ), + await cloud.client.async_system_message({"region": "xx-earth-616"}) + await set_cloud_prefs( + {"alexa_enabled": True, "google_enabled": False, "remote_enabled": True} ) info = await get_system_health_info(hass, "cloud") @@ -59,8 +59,8 @@ async def test_cloud_system_health( assert info == { "logged_in": True, - "subscription_expiration": now, - "certificate_status": "ready", + "subscription_expiration": cloud.expiration_date, + "certificate_status": CertificateStatus.READY, "relayer_connected": True, "relayer_region": "xx-earth-616", "remote_enabled": True, @@ -71,5 +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", + "instance_id": cloud.client.prefs.instance_id, } diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index ba88ae2af2d4b4..dc32747182d484 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -1,23 +1,35 @@ """Tests for cloud tts.""" -from unittest.mock import Mock +from collections.abc import Callable, Coroutine +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock, MagicMock -from hass_nabucasa import voice +from hass_nabucasa.voice import MAP_VOICE, VoiceError import pytest import voluptuous as vol -from homeassistant.components.cloud import const, tts +from homeassistant.components.cloud import DOMAIN, const, tts +from homeassistant.components.tts import DOMAIN as TTS_DOMAIN +from homeassistant.components.tts.helper import get_engine_instance +from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator -@pytest.fixture -def cloud_with_prefs(cloud_prefs): - """Return a cloud mock with prefs.""" - return Mock(client=Mock(prefs=cloud_prefs)) + +@pytest.fixture(autouse=True) +async def internal_url_mock(hass: HomeAssistant) -> None: + """Mock internal URL of the instance.""" + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) def test_default_exists() -> None: """Test our default language exists.""" - assert const.DEFAULT_TTS_DEFAULT_VOICE in voice.MAP_VOICE + assert const.DEFAULT_TTS_DEFAULT_VOICE in MAP_VOICE def test_schema() -> None: @@ -42,54 +54,138 @@ def test_schema() -> None: tts.PLATFORM_SCHEMA({"platform": "cloud"}) +@pytest.mark.parametrize( + ("engine_id", "platform_config"), + [ + ( + DOMAIN, + None, + ), + ( + DOMAIN, + { + "platform": DOMAIN, + "service_name": "yaml", + "language": "fr-FR", + "gender": "female", + }, + ), + ], +) async def test_prefs_default_voice( - hass: HomeAssistant, cloud_with_prefs, cloud_prefs + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + engine_id: str, + platform_config: dict[str, Any] | None, ) -> None: """Test cloud provider uses the preferences.""" - assert cloud_prefs.tts_default_voice == ("en-US", "female") + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, TTS_DOMAIN, {TTS_DOMAIN: platform_config}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - tts_info = {"platform_loaded": Mock()} - provider_pref = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info - ) - provider_conf = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), - {"language": "fr-FR", "gender": "female"}, - None, - ) + assert cloud.client.prefs.tts_default_voice == ("en-US", "female") - assert provider_pref.default_language == "en-US" - assert provider_pref.default_options == {"gender": "female", "audio_output": "mp3"} - assert provider_conf.default_language == "fr-FR" - assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"} + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() - await cloud_prefs.async_update(tts_default_voice=("nl-NL", "male")) - await hass.async_block_till_done() + engine = get_engine_instance(hass, engine_id) - assert provider_pref.default_language == "nl-NL" - assert provider_pref.default_options == {"gender": "male", "audio_output": "mp3"} - assert provider_conf.default_language == "fr-FR" - assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"} + assert engine is not None + # The platform config provider will be overridden by the discovery info provider. + assert engine.default_language == "en-US" + assert engine.default_options == {"gender": "female", "audio_output": "mp3"} + await set_cloud_prefs({"tts_default_voice": ("nl-NL", "male")}) + await hass.async_block_till_done() -async def test_provider_properties(cloud_with_prefs) -> None: - """Test cloud provider.""" - tts_info = {"platform_loaded": Mock()} - provider = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info - ) - assert provider.supported_options == ["gender", "voice", "audio_output"] - assert "nl-NL" in provider.supported_languages - assert tts.Voice( - "ColetteNeural", "ColetteNeural" - ) in provider.async_get_supported_voices("nl-NL") + assert engine.default_language == "nl-NL" + assert engine.default_options == {"gender": "male", "audio_output": "mp3"} -async def test_get_tts_audio(cloud_with_prefs) -> None: +async def test_provider_properties( + hass: HomeAssistant, + cloud: MagicMock, +) -> None: + """Test cloud provider.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + + engine = get_engine_instance(hass, DOMAIN) + + assert engine is not None + assert engine.supported_options == ["gender", "voice", "audio_output"] + assert "nl-NL" in engine.supported_languages + supported_voices = engine.async_get_supported_voices("nl-NL") + assert supported_voices is not None + assert tts.Voice("ColetteNeural", "ColetteNeural") in supported_voices + supported_voices = engine.async_get_supported_voices("missing_language") + assert supported_voices is None + + +@pytest.mark.parametrize( + ("data", "expected_url_suffix"), + [ + ({"platform": DOMAIN}, DOMAIN), + ({"engine_id": DOMAIN}, DOMAIN), + ], +) +@pytest.mark.parametrize( + ("mock_process_tts_return_value", "mock_process_tts_side_effect"), + [ + (b"", None), + (None, VoiceError("Boom!")), + ], +) +async def test_get_tts_audio( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + cloud: MagicMock, + data: dict[str, Any], + expected_url_suffix: str, + mock_process_tts_return_value: bytes | None, + mock_process_tts_side_effect: Exception | None, +) -> None: """Test cloud provider.""" - tts_info = {"platform_loaded": Mock()} - provider = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info + mock_process_tts = AsyncMock( + return_value=mock_process_tts_return_value, + side_effect=mock_process_tts_side_effect, ) - assert provider.supported_options == ["gender", "voice", "audio_output"] - assert "nl-NL" in provider.supported_languages + cloud.voice.process_tts = mock_process_tts + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + client = await hass_client() + + url = "/api/tts_get_url" + data |= {"message": "There is someone at the door."} + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + ), + } + await hass.async_block_till_done() + + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + assert mock_process_tts.call_args.kwargs["language"] == "en-US" + assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" diff --git a/tests/components/co2signal/__init__.py b/tests/components/co2signal/__init__.py index 1f3d6a83c056cb..65764d75fe47c0 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 00000000000000..8eb0116bc88563 --- /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 ffb35edfbbbca2..645e0bd87e9855 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'co2signal', 'entry_id': '904a74160aa6f335526706bee85dfb83', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -19,14 +20,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 00000000000000..eb4364ed0d641e --- /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 879293ae9590ca..5b1ade1ee49150 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 ed73cb960b50ac..edc0007952b881 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 00000000000000..b79c8e04c23f9f --- /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/coinbase/common.py b/tests/components/coinbase/common.py index 6ab33f3bc7c4e1..0f8930dbeffad6 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -6,7 +6,12 @@ ) from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN -from .const import GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2, MOCK_ACCOUNTS_RESPONSE +from .const import ( + GOOD_CURRENCY_2, + GOOD_EXCHANGE_RATE, + GOOD_EXCHANGE_RATE_2, + MOCK_ACCOUNTS_RESPONSE, +) from tests.common import MockConfigEntry @@ -60,7 +65,11 @@ def mock_get_exchange_rates(): """Return a heavily reduced mock list of exchange rates for testing.""" return { "currency": "USD", - "rates": {GOOD_EXCHANGE_RATE_2: "0.109", GOOD_EXCHANGE_RATE: "0.00002"}, + "rates": { + GOOD_CURRENCY_2: "1.0", + GOOD_EXCHANGE_RATE_2: "0.109", + GOOD_EXCHANGE_RATE: "0.00002", + }, } diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py index 2b437e15478453..138b941c62cb54 100644 --- a/tests/components/coinbase/const.py +++ b/tests/components/coinbase/const.py @@ -12,26 +12,23 @@ MOCK_ACCOUNTS_RESPONSE = [ { "balance": {"amount": "0.00001", "currency": GOOD_CURRENCY}, - "currency": GOOD_CURRENCY, + "currency": {"code": GOOD_CURRENCY}, "id": "123456789", "name": "BTC Wallet", - "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, "type": "wallet", }, { "balance": {"amount": "100.00", "currency": GOOD_CURRENCY}, - "currency": GOOD_CURRENCY, + "currency": {"code": GOOD_CURRENCY}, "id": "abcdefg", "name": "BTC Vault", - "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, "type": "vault", }, { "balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, - "currency": "USD", + "currency": {"code": GOOD_CURRENCY_2}, "id": "987654321", "name": "USD Wallet", - "native_balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, "type": "fiat", }, ] diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index c214330d5f9ecf..9079a7682c803c 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -7,13 +7,11 @@ 'amount': '**REDACTED**', 'currency': 'BTC', }), - 'currency': 'BTC', + 'currency': dict({ + 'code': 'BTC', + }), 'id': '**REDACTED**', 'name': 'BTC Wallet', - 'native_balance': dict({ - 'amount': '**REDACTED**', - 'currency': 'USD', - }), 'type': 'wallet', }), dict({ @@ -21,13 +19,11 @@ 'amount': '**REDACTED**', 'currency': 'BTC', }), - 'currency': 'BTC', + 'currency': dict({ + 'code': 'BTC', + }), 'id': '**REDACTED**', 'name': 'BTC Vault', - 'native_balance': dict({ - 'amount': '**REDACTED**', - 'currency': 'USD', - }), 'type': 'vault', }), dict({ @@ -35,13 +31,11 @@ 'amount': '**REDACTED**', 'currency': 'USD', }), - 'currency': 'USD', + 'currency': dict({ + 'code': 'USD', + }), 'id': '**REDACTED**', 'name': 'USD Wallet', - 'native_balance': dict({ - 'amount': '**REDACTED**', - 'currency': 'USD', - }), 'type': 'fiat', }), ]), @@ -53,6 +47,7 @@ 'disabled_by': None, 'domain': 'coinbase', 'entry_id': '080272b77a4f80c41b94d7cdc86fd826', + 'minor_version': 1, 'options': dict({ 'account_balance_currencies': list([ ]), diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 10999b04bea0d0..998c12c09b701e 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,7 +1,9 @@ """Common stuff for Comelit SimpleHome tests.""" +from aiocomelit.const import VEDO + from homeassistant.components.comelit.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE MOCK_CONFIG = { DOMAIN: { @@ -10,11 +12,18 @@ CONF_HOST: "fake_host", CONF_PORT: 80, CONF_PIN: 1234, - } + }, + { + CONF_HOST: "fake_vedo_host", + CONF_PORT: 8080, + CONF_PIN: 1234, + CONF_TYPE: VEDO, + }, ] } } -MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] +MOCK_USER_BRIDGE_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] +MOCK_USER_VEDO_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][1] FAKE_PIN = 5678 diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index f2d59f46114fe8..f17c46c6f5b56f 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for Comelit SimpleHome config flow.""" +from typing import Any from unittest.mock import patch from aiocomelit import CannotAuthenticate, CannotConnect @@ -10,24 +11,27 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import FAKE_PIN, MOCK_USER_DATA +from .const import FAKE_PIN, MOCK_USER_BRIDGE_DATA, MOCK_USER_VEDO_DATA from tests.common import MockConfigEntry -async def test_user(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("class_api", "user_input"), + [ + ("ComeliteSerialBridgeApi", MOCK_USER_BRIDGE_DATA), + ("ComelitVedoApi", MOCK_USER_VEDO_DATA), + ], +) +async def test_full_flow( + hass: HomeAssistant, class_api: str, user_input: dict[str, Any] +) -> None: """Test starting a flow by user.""" with patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - ), patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", + f"aiocomelit.api.{class_api}.login", ), patch( - "homeassistant.components.comelit.async_setup_entry" - ) as mock_setup_entry, patch( - "requests.get" - ) as mock_request_get: - mock_request_get.return_value.status_code = 200 - + f"aiocomelit.api.{class_api}.logout", + ), patch("homeassistant.components.comelit.async_setup_entry") as mock_setup_entry: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -35,12 +39,12 @@ async def test_user(hass: HomeAssistant) -> None: assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA + result["flow_id"], user_input=user_input ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_PORT] == 80 - assert result["data"][CONF_PIN] == 1234 + assert result["data"][CONF_HOST] == user_input[CONF_HOST] + assert result["data"][CONF_PORT] == user_input[CONF_PORT] + assert result["data"][CONF_PIN] == user_input[CONF_PIN] assert not result["result"].unique_id await hass.async_block_till_done() @@ -70,10 +74,10 @@ 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 + result["flow_id"], user_input=MOCK_USER_BRIDGE_DATA ) assert result["type"] == FlowResultType.FORM @@ -84,7 +88,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> async def test_reauth_successful(hass: HomeAssistant) -> None: """Test starting a reauthentication flow.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) mock_config.add_to_hass(hass) with patch( @@ -128,16 +132,14 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: """Test starting a reauthentication flow but no connection found.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) mock_config.add_to_hass(hass) with patch( "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/config/test_automation.py b/tests/components/config/test_automation.py index abe0ed90e86588..1a099c05b16ab0 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 3cc7ada49ba393..414f4eb39f2f39 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -19,8 +19,8 @@ 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 async_step_user(self, user_input=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 async_step_user(self, user_input=None): 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)) @@ -532,6 +532,7 @@ async def async_step_user(self, user_input=None): "description": None, "description_placeholders": None, "options": {}, + "minor_version": 1, } @@ -542,7 +543,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 @@ -609,6 +610,7 @@ async def async_step_account(self, user_input=None): "description": None, "description_placeholders": None, "options": {}, + "minor_version": 1, } @@ -619,7 +621,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 +668,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 +716,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 +752,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 +806,7 @@ async def async_step_user(self, user_input=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 +864,7 @@ async def async_step_init(self, user_input=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", @@ -883,7 +885,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 @@ -942,6 +944,7 @@ async def async_step_finish(self, user_input=None): "version": 1, "description": None, "description_placeholders": None, + "minor_version": 1, } @@ -950,7 +953,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 +1268,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_device_registry.py b/tests/components/config/test_device_registry.py index 87bb9cc940903d..4a784a6eff1f13 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -242,7 +242,7 @@ async def async_remove_config_entry_device(hass, config_entry, device_entry): 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 async_remove_config_entry_device(hass, config_entry, device_entry): 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 async_remove_config_entry_device(hass, config_entry, device_entry): 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 async_remove_config_entry_device(hass, config_entry, device_entry): 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 async_remove_config_entry_device(hass, config_entry, device_entry): 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 async_remove_config_entry_device(hass, config_entry, device_entry): 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 1f09d5e9989833..9fd596f7f91fc5 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 cc0352301b4b50..7cf8cf5833ebb6 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/conftest.py b/tests/components/conftest.py index c985565b1bea48..adf79a2ef96c50 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -16,7 +16,7 @@ def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: yield -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def prevent_io() -> Generator[None, None, None]: """Fixture to prevent certain I/O from happening.""" with patch( @@ -91,3 +91,12 @@ def tts_mutagen_mock_fixture(): from tests.components.tts.common import tts_mutagen_mock_fixture_helper yield from tts_mutagen_mock_fixture_helper() + + +@pytest.fixture(scope="session", autouse=True) +def prevent_ffmpeg_subprocess() -> Generator[None, None, None]: + """Prevent ffmpeg from creating a subprocess.""" + with patch( + "homeassistant.components.ffmpeg.FFVersion.get_version", return_value="6.0" + ): + yield diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index f7145a9ab56ab9..b68f2fb87012ba 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,4 +1,104 @@ # serializer version: 1 +# name: test_custom_agent + dict({ + 'conversation_id': 'test-conv-id', + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'test-language', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Test response', + }), + }), + }), + }) +# --- +# name: test_custom_sentences + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en-us', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'You ordered a stout', + }), + }), + }), + }) +# --- +# name: test_custom_sentences.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en-us', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'You ordered a lager', + }), + }), + }), + }) +# --- +# name: test_custom_sentences_config + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Stealth mode engaged', + }), + }), + }), + }) +# --- # name: test_get_agent_info dict({ 'id': 'homeassistant', @@ -225,7 +325,67 @@ ]), }) # --- -# name: test_turn_on_intent[turn kitchen on-None] +# name: test_http_api_handle_failure + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'failed_to_handle', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'An unexpected error occurred while handling the intent', + }), + }), + }), + }) +# --- +# name: test_http_api_no_match + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named do something', + }), + }), + }), + }) +# --- +# name: test_http_api_unexpected_failure + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'An unexpected error occurred while handling the intent', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent[None] dict({ 'conversation_id': None, 'response': dict({ @@ -238,7 +398,7 @@ dict({ 'id': 'light.kitchen', 'name': 'kitchen', - 'type': , + 'type': 'entity', }), ]), 'targets': list([ @@ -249,13 +409,13 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), }) # --- -# name: test_turn_on_intent[turn kitchen on-homeassistant] +# name: test_http_processing_intent[homeassistant] dict({ 'conversation_id': None, 'response': dict({ @@ -268,7 +428,7 @@ dict({ 'id': 'light.kitchen', 'name': 'kitchen', - 'type': , + 'type': 'entity', }), ]), 'targets': list([ @@ -279,13 +439,13 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), }) # --- -# name: test_turn_on_intent[turn on kitchen-None] +# name: test_http_processing_intent_alias_added_removed dict({ 'conversation_id': None, 'response': dict({ @@ -297,8 +457,8 @@ 'success': list([ dict({ 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , + 'name': 'kitchen light', + 'type': 'entity', }), ]), 'targets': list([ @@ -309,13 +469,13 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), }) # --- -# name: test_turn_on_intent[turn on kitchen-homeassistant] +# name: test_http_processing_intent_alias_added_removed.1 dict({ 'conversation_id': None, 'response': dict({ @@ -327,8 +487,8 @@ 'success': list([ dict({ 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , + 'name': 'kitchen light', + 'type': 'entity', }), ]), 'targets': list([ @@ -339,52 +499,912 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), }) # --- -# name: test_ws_get_agent_info +# name: test_http_processing_intent_alias_added_removed.2 dict({ - 'attribution': None, + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named late added alias', + }), + }), + }), }) # --- -# name: test_ws_get_agent_info.1 +# name: test_http_processing_intent_conversion_not_expose_new dict({ - 'attribution': None, + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named kitchen', + }), + }), + }), }) # --- -# name: test_ws_get_agent_info.2 +# name: test_http_processing_intent_conversion_not_expose_new.1 dict({ - 'attribution': dict({ - 'name': 'Mock assistant', - 'url': 'https://assist.me', + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), }), }) # --- -# name: test_ws_get_agent_info.3 +# name: test_http_processing_intent_entity_added_removed dict({ - 'code': 'invalid_format', - 'message': "invalid agent ID for dictionary value @ data['agent_id']. Got 'not_exist'", + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), }) # --- -# name: test_ws_hass_agent_debug +# name: test_http_processing_intent_entity_added_removed.1 dict({ - 'results': list([ - dict({ - 'details': dict({ - 'name': dict({ - 'name': 'name', - 'text': 'my cool light', - 'value': 'my cool light', + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.late', + 'name': 'friendly light', + 'type': 'entity', }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', }), - 'intent': dict({ - 'name': 'HassTurnOn', - }), - 'slots': dict({ - 'name': 'my cool light', + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_added_removed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.late', + 'name': 'friendly light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_added_removed.3 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named late added', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named kitchen', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed.3 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named my cool', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed.4 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed.5 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_renamed + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_renamed.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'renamed light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_renamed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named kitchen', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_renamed.3 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_renamed.4 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named renamed', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_target_ha_agent + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[None-turn kitchen on-None] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[None-turn kitchen on-homeassistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[None-turn on kitchen-None] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[None-turn on kitchen-homeassistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_ws_api[payload0] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named test text', + }), + }), + }), + }) +# --- +# name: test_ws_api[payload1] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'test-language', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_api[payload2] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named test text', + }), + }), + }), + }) +# --- +# name: test_ws_api[payload3] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named test text', + }), + }), + }), + }) +# --- +# name: test_ws_api[payload4] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'test-language', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_api[payload5] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named test text', + }), + }), + }), + }) +# --- +# name: test_ws_get_agent_info + dict({ + 'attribution': None, + }) +# --- +# name: test_ws_get_agent_info.1 + dict({ + 'attribution': None, + }) +# --- +# name: test_ws_get_agent_info.2 + dict({ + 'attribution': dict({ + 'name': 'Mock assistant', + 'url': 'https://assist.me', + }), + }) +# --- +# name: test_ws_get_agent_info.3 + dict({ + 'code': 'invalid_format', + 'message': "invalid agent ID for dictionary value @ data['agent_id']. Got 'not_exist'", + }) +# --- +# name: test_ws_hass_agent_debug + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'name': dict({ + 'name': 'name', + 'text': 'my cool light', + 'value': 'my cool light', + }), + }), + 'intent': dict({ + 'name': 'HassTurnOn', + }), + 'slots': dict({ + 'name': 'my cool light', }), 'targets': dict({ 'light.kitchen': dict({ @@ -470,7 +1490,23 @@ }), }), }), - None, + dict({ + 'details': dict({ + 'domain': dict({ + 'name': 'domain', + 'text': '', + 'value': 'script', + }), + }), + 'intent': dict({ + 'name': 'HassTurnOn', + }), + 'slots': dict({ + 'domain': 'script', + }), + 'targets': dict({ + }), + }), ]), }) # --- diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index c75c96ca59bc95..4c1d395a2cc115 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 @@ -56,7 +57,7 @@ async def test_hidden_entities_skipped( assert len(calls) == 0 assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS async def test_exposed_domains(hass: HomeAssistant, init_components) -> None: @@ -69,10 +70,10 @@ async def test_exposed_domains(hass: HomeAssistant, init_components) -> None: hass, "turn on test media player", None, Context(), None ) - # This is an intent match failure instead of a handle failure because the - # media player domain is not exposed. + # This is a match failure instead of a handle failure because the media + # player domain is not exposed. assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS async def test_exposed_areas( @@ -126,9 +127,9 @@ async def test_exposed_areas( hass, "turn on lights in the bedroom", None, Context(), None ) - # This should be an intent match failure because the area isn't in the slot list + # This should be a match failure because the area isn't in the slot list assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS async def test_conversation_agent( @@ -293,3 +294,172 @@ 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.id].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 + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_kitchen.id + + # 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 + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_bedroom.id + + # 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 + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_bedroom.id + + # 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_VALID_TARGETS + ) + + +async def test_error_missing_entity(hass: HomeAssistant, init_components) -> None: + """Test error message when entity is missing.""" + result = await conversation.async_converse( + hass, "turn on missing entity", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "No device or entity named missing entity" + ) + + +async def test_error_missing_area(hass: HomeAssistant, init_components) -> None: + """Test error message when area is missing.""" + result = await conversation.async_converse( + hass, "turn on the lights in missing area", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert result.response.speech["plain"]["speech"] == "No area named missing area" + + +async def test_error_match_failure(hass: HomeAssistant, init_components) -> None: + """Test response with complete match failure.""" + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[], + ): + result = await conversation.async_converse( + hass, "do something", 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 fdbf10b0c7f347..b3167d979d5b0c 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -58,6 +58,7 @@ async def test_http_processing_intent( hass_admin_user: MockUser, agent_id, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API.""" # Add an alias @@ -78,27 +79,7 @@ async def test_http_processing_intent( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot async def test_http_processing_intent_target_ha_agent( @@ -108,6 +89,7 @@ async def test_http_processing_intent_target_ha_agent( hass_admin_user: MockUser, mock_agent, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent can be processed via HTTP API with picking agent.""" # Add an alias @@ -127,28 +109,8 @@ async def test_http_processing_intent_target_ha_agent( assert resp.status == HTTPStatus.OK assert len(calls) == 1 data = await resp.json() - - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" async def test_http_processing_intent_entity_added_removed( @@ -157,6 +119,7 @@ async def test_http_processing_intent_entity_added_removed( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with entities added later. @@ -179,27 +142,8 @@ async def test_http_processing_intent_entity_added_removed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Add an entity entity_registry.async_get_or_create( @@ -215,27 +159,8 @@ async def test_http_processing_intent_entity_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.late", "name": "friendly light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Now add an alias entity_registry.async_update_entity("light.late", aliases={"late added light"}) @@ -248,27 +173,8 @@ async def test_http_processing_intent_entity_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.late", "name": "friendly light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Now delete the entity hass.states.async_remove("light.late") @@ -280,21 +186,8 @@ async def test_http_processing_intent_entity_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_processing_intent_alias_added_removed( @@ -303,6 +196,7 @@ async def test_http_processing_intent_alias_added_removed( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with aliases added later. @@ -324,27 +218,8 @@ async def test_http_processing_intent_alias_added_removed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Add an alias entity_registry.async_update_entity("light.kitchen", aliases={"late added alias"}) @@ -357,27 +232,8 @@ async def test_http_processing_intent_alias_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Now remove the alieas entity_registry.async_update_entity("light.kitchen", aliases={}) @@ -389,21 +245,8 @@ async def test_http_processing_intent_alias_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_processing_intent_entity_renamed( @@ -413,6 +256,7 @@ async def test_http_processing_intent_entity_renamed( hass_admin_user: MockUser, entity_registry: er.EntityRegistry, enable_custom_integrations: None, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with entities renamed later. @@ -442,27 +286,8 @@ async def test_http_processing_intent_entity_renamed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Rename the entity entity_registry.async_update_entity("light.kitchen", name="renamed light") @@ -476,27 +301,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "renamed light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" client = await hass_client() resp = await client.post( @@ -505,21 +311,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" # Now clear the custom name entity_registry.async_update_entity("light.kitchen", name=None) @@ -533,27 +326,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" client = await hass_client() resp = await client.post( @@ -562,21 +336,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_processing_intent_entity_exposed( @@ -586,6 +347,7 @@ async def test_http_processing_intent_entity_exposed( hass_admin_user: MockUser, entity_registry: er.EntityRegistry, enable_custom_integrations: None, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with manual expose. @@ -617,27 +379,8 @@ async def test_http_processing_intent_entity_exposed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") client = await hass_client() @@ -649,27 +392,8 @@ async def test_http_processing_intent_entity_exposed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Unexpose the entity expose_entity(hass, "light.kitchen", False) @@ -682,21 +406,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" client = await hass_client() resp = await client.post( @@ -705,21 +416,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" # Now expose the entity expose_entity(hass, "light.kitchen", True) @@ -733,27 +431,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" client = await hass_client() resp = await client.post( @@ -762,27 +441,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" async def test_http_processing_intent_conversion_not_expose_new( @@ -792,6 +452,7 @@ async def test_http_processing_intent_conversion_not_expose_new( hass_admin_user: MockUser, entity_registry: er.EntityRegistry, enable_custom_integrations: None, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API when not exposing new entities.""" # Disable exposing new entities to the default agent @@ -820,21 +481,8 @@ async def test_http_processing_intent_conversion_not_expose_new( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" # Expose the entity expose_entity(hass, "light.kitchen", True) @@ -848,33 +496,15 @@ async def test_http_processing_intent_conversion_not_expose_new( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on")) +@pytest.mark.parametrize("conversation_id", ("my_new_conversation", None)) async def test_turn_on_intent( - hass: HomeAssistant, init_components, sentence, agent_id, snapshot + hass: HomeAssistant, init_components, conversation_id, sentence, agent_id, snapshot ) -> None: """Test calling the turn on intent.""" hass.states.async_set("light.kitchen", "off") @@ -883,6 +513,8 @@ async def test_turn_on_intent( data = {conversation.ATTR_TEXT: sentence} if agent_id is not None: data[conversation.ATTR_AGENT_ID] = agent_id + if conversation_id is not None: + data[conversation.ATTR_CONVERSATION_ID] = conversation_id result = await hass.services.async_call( "conversation", "process", @@ -933,7 +565,10 @@ async def test_turn_off_intent(hass: HomeAssistant, init_components, sentence) - async def test_http_api_no_match( - hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the HTTP conversation API with an intent match failure.""" client = await hass_client() @@ -944,25 +579,15 @@ async def test_http_api_no_match( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "speech": "Sorry, I couldn't understand that", - "extra_data": None, - }, - }, - "language": hass.config.language, - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_api_handle_failure( - hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the HTTP conversation API with an error during handling.""" client = await hass_client() @@ -981,29 +606,16 @@ def async_handle_error(*args, **kwargs): assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "An unexpected error occurred while handling the intent", - } - }, - "language": hass.config.language, - "data": { - "code": "failed_to_handle", - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" + assert data["response"]["data"]["code"] == "failed_to_handle" async def test_http_api_unexpected_failure( hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the HTTP conversation API with an unexpected error during handling.""" client = await hass_client() @@ -1022,23 +634,9 @@ def async_handle_error(*args, **kwargs): assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "An unexpected error occurred while handling the intent", - } - }, - "language": hass.config.language, - "data": { - "code": "unknown", - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" + assert data["response"]["data"]["code"] == "unknown" async def test_http_api_wrong_data( @@ -1059,6 +657,7 @@ async def test_custom_agent( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, mock_agent, + snapshot: SnapshotAssertion, ) -> None: """Test a custom conversation agent.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1076,21 +675,11 @@ async def test_custom_agent( resp = await client.post("/api/conversation/process", json=data) assert resp.status == HTTPStatus.OK - assert await resp.json() == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Test response", - } - }, - "language": "test-language", - "data": {"targets": [], "success": [], "failed": []}, - }, - "conversation_id": "test-conv-id", - } + data = await resp.json() + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + assert data["response"]["speech"]["plain"]["speech"] == "Test response" + assert data["conversation_id"] == "test-conv-id" assert len(mock_agent.calls) == 1 assert mock_agent.calls[0].text == "Test Text" @@ -1133,7 +722,10 @@ async def test_custom_agent( ], ) async def test_ws_api( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, payload + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + payload, + snapshot: SnapshotAssertion, ) -> None: """Test the Websocket conversation API.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1145,21 +737,7 @@ async def test_ws_api( msg = await client.receive_json() assert msg["success"] - assert msg["result"] == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - "language": payload.get("language", hass.config.language), - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } + assert msg["result"] == snapshot @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @@ -1195,7 +773,10 @@ async def test_ws_prepare( async def test_custom_sentences( - hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + snapshot: SnapshotAssertion, ) -> None: """Test custom sentences with a custom intent.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1220,30 +801,19 @@ async def test_custom_sentences( ) assert resp.status == HTTPStatus.OK data = await resp.json() - - assert data == { - "response": { - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": f"You ordered a {beer_style}", - } - }, - "language": language, - "response_type": "action_done", - "data": { - "targets": [], - "success": [], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + assert ( + data["response"]["speech"]["plain"]["speech"] + == f"You ordered a {beer_style}" + ) async def test_custom_sentences_config( - hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + snapshot: SnapshotAssertion, ) -> None: """Test custom sentences with a custom intent in config.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1271,26 +841,9 @@ async def test_custom_sentences_config( ) assert resp.status == HTTPStatus.OK data = await resp.json() - - assert data == { - "response": { - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Stealth mode engaged", - } - }, - "language": hass.config.language, - "response_type": "action_done", - "data": { - "targets": [], - "success": [], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + assert data["response"]["speech"]["plain"]["speech"] == "Stealth mode engaged" async def test_prepare_reload(hass: HomeAssistant) -> None: diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 1275036346951b..53bec13d5670a2 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -502,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) @@ -525,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( @@ -546,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 @@ -554,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) @@ -627,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/cover/test_init.py b/tests/components/cover/test_init.py index 802bf759d81a30..1b08658d9839d3 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -1,4 +1,8 @@ """The tests for Cover.""" +from enum import Enum + +import pytest + import homeassistant.components.cover as cover from homeassistant.const import ( ATTR_ENTITY_ID, @@ -12,6 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import import_and_test_deprecated_constant_enum + async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -> None: """Test the provided services.""" @@ -112,3 +118,43 @@ def is_closed(hass, ent): def is_closing(hass, ent): """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(ent.entity_id, STATE_CLOSING) + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(cover.CoverEntityFeature, "SUPPORT_") + + _create_tuples(cover.CoverDeviceClass, "DEVICE_CLASS_"), +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, cover, enum, constant_prefix, "2025.1" + ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCoverEntity(cover.CoverEntity): + _attr_supported_features = 1 + + entity = MockCoverEntity() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "MockCoverEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CoverEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/cover/test_significant_change.py b/tests/components/cover/test_significant_change.py new file mode 100644 index 00000000000000..9ddb2cb9498cca --- /dev/null +++ b/tests/components/cover/test_significant_change.py @@ -0,0 +1,65 @@ +"""Test the Cover significant change platform.""" +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, +) +from homeassistant.components.cover.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Cover significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # float attributes + ({ATTR_CURRENT_POSITION: 60.0}, {ATTR_CURRENT_POSITION: 61.0}, True), + ({ATTR_CURRENT_POSITION: 60.0}, {ATTR_CURRENT_POSITION: 60.9}, False), + ({ATTR_CURRENT_POSITION: "invalid"}, {ATTR_CURRENT_POSITION: 60.0}, True), + ({ATTR_CURRENT_POSITION: 60.0}, {ATTR_CURRENT_POSITION: "invalid"}, False), + ({ATTR_CURRENT_TILT_POSITION: 60.0}, {ATTR_CURRENT_TILT_POSITION: 61.0}, True), + ({ATTR_CURRENT_TILT_POSITION: 60.0}, {ATTR_CURRENT_TILT_POSITION: 60.9}, False), + # multiple attributes + ( + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 60, + }, + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 61, + }, + True, + ), + ( + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 59.1, + }, + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 60.9, + }, + True, + ), + # insignificant attributes + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Cover significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/cpuspeed/test_sensor.py b/tests/components/cpuspeed/test_sensor.py index 625f80a681451c..457d9c37d1417c 100644 --- a/tests/components/cpuspeed/test_sensor.py +++ b/tests/components/cpuspeed/test_sensor.py @@ -25,13 +25,13 @@ 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/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr index bbd96f1751cfcb..911f2e134f2636 100644 --- a/tests/components/deconz/snapshots/test_diagnostics.ambr +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -12,6 +12,7 @@ 'disabled_by': None, 'domain': 'deconz', 'entry_id': '1', + 'minor_version': 1, 'options': dict({ 'master': True, }), diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 5a3952e16db30a..dd0de559ba8b08 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -41,6 +41,7 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .test_gateway import ( DECONZ_WEB_REQUEST, @@ -602,7 +603,7 @@ async def test_climate_device_with_fan_support( # Service set fan mode to unsupported value - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -725,7 +726,7 @@ async def test_climate_device_with_preset( # Service set preset to unsupported value - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 357371e4853b8f..d38c65526c2e26 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -186,7 +186,6 @@ async def test_no_lights_or_groups( "state": STATE_ON, "attributes": { ATTR_EFFECT_LIST: [ - EFFECT_COLORLOOP, "carnival", "collide", "fading", diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 7fa93266aef14e..38d68d135b69e5 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -530,6 +530,55 @@ async def test_no_sensors( "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": { diff --git a/tests/components/demo/test_button.py b/tests/components/demo/test_button.py index bcaddab433b8d5..6049de12570e75 100644 --- a/tests/components/demo/test_button.py +++ b/tests/components/demo/test_button.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.button import DOMAIN, SERVICE_PRESS @@ -37,20 +38,20 @@ def test_setup_params(hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN -async def test_press(hass: HomeAssistant) -> None: +async def test_press(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test pressing the button.""" state = hass.states.get(ENTITY_PUSH) assert state assert state.state == STATE_UNKNOWN now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: ENTITY_PUSH}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_PUSH}, + blocking=True, + ) state = hass.states.get(ENTITY_PUSH) assert state diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index 69e385ce242ad5..97b436ea2b0e6e 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -278,12 +278,12 @@ async def test_set_fan_mode(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_FAN_MODE: "On Low"}, + {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_FAN_MODE: "on_low"}, blocking=True, ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_FAN_MODE) == "On Low" + assert state.attributes.get(ATTR_FAN_MODE) == "on_low" async def test_set_swing_mode_bad_attr(hass: HomeAssistant) -> None: @@ -311,12 +311,12 @@ async def test_set_swing(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_SWING_MODE: "Auto"}, + {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_SWING_MODE: "auto"}, blocking=True, ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_SWING_MODE) == "Auto" + assert state.attributes.get(ATTR_SWING_MODE) == "auto" async def test_set_hvac_bad_attr_and_state(hass: HomeAssistant) -> None: diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 58a8c99ea3c225..a3f607aee769e8 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/demo/test_select.py b/tests/components/demo/test_select.py index a4fff2a231e6d5..013a9900a8355b 100644 --- a/tests/components/demo/test_select.py +++ b/tests/components/demo/test_select.py @@ -11,6 +11,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component ENTITY_SPEED = "select.speed" @@ -51,7 +52,7 @@ async def test_select_option_bad_attr(hass: HomeAssistant) -> None: assert state assert state.state == "ridiculous_speed" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py index 93a6305655bb96..a0fb908d920000 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/devialet/__init__.py b/tests/components/devialet/__init__.py new file mode 100644 index 00000000000000..28ab6229c447b0 --- /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 00000000000000..2b9761cc03a1d7 --- /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 00000000000000..be9ea651d6e016 --- /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 00000000000000..6ff1a724f086cc --- /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 00000000000000..e61cc12151dd69 --- /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 00000000000000..ac16468597dc93 --- /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 00000000000000..d389675ac98e05 --- /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 00000000000000..5f484314d73c96 --- /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 00000000000000..f496e5557d22a1 --- /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 00000000000000..365d5ed776d0dd --- /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 00000000000000..0bacc558b74d75 --- /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 00000000000000..82600de7cf5d10 --- /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 00000000000000..86d383e91d8d22 --- /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 00000000000000..56381bf6de420e --- /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 724ae612f0db93..ada1c03a92367c 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -2,6 +2,7 @@ from datetime import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import ( @@ -72,13 +73,15 @@ async def scanner(hass, enable_custom_integrations): return scanner -async def test_lights_on_when_sun_sets(hass: HomeAssistant, scanner) -> None: +async def test_lights_on_when_sun_sets( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, scanner +) -> None: """Test lights go on when there is someone home and the sun sets.""" test_time = datetime(2017, 4, 5, 1, 2, 3, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - assert await async_setup_component( - hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} - ) + freezer.move_to(test_time) + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} + ) await hass.services.async_call( light.DOMAIN, @@ -88,9 +91,9 @@ async def test_lights_on_when_sun_sets(hass: HomeAssistant, scanner) -> None: ) test_time = test_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() + freezer.move_to(test_time) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == STATE_ON @@ -128,22 +131,22 @@ async def test_lights_turn_off_when_everyone_leaves( async def test_lights_turn_on_when_coming_home_after_sun_set( - hass: HomeAssistant, scanner + hass: HomeAssistant, freezer: FrozenDateTimeFactory, scanner ) -> None: """Test lights turn on when coming home after sun set.""" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - await hass.services.async_call( - light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True - ) + freezer.move_to(test_time) + await hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True + ) - assert await async_setup_component( - hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} - ) + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} + ) - hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) + hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) - await hass.async_block_till_done() + await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == light.STATE_ON @@ -152,85 +155,85 @@ async def test_lights_turn_on_when_coming_home_after_sun_set( async def test_lights_turn_on_when_coming_home_after_sun_set_person( - hass: HomeAssistant, scanner + hass: HomeAssistant, freezer: FrozenDateTimeFactory, scanner ) -> None: """Test lights turn on when coming home after sun set.""" device_1 = f"{DOMAIN}.device_1" device_2 = f"{DOMAIN}.device_2" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - await hass.services.async_call( - light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True - ) - hass.states.async_set(device_1, STATE_NOT_HOME) - hass.states.async_set(device_2, STATE_NOT_HOME) - await hass.async_block_till_done() + freezer.move_to(test_time) + await hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True + ) + hass.states.async_set(device_1, STATE_NOT_HOME) + hass.states.async_set(device_2, STATE_NOT_HOME) + await hass.async_block_till_done() - assert all( - not light.is_on(hass, ent_id) - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "not_home" - assert hass.states.get(device_2).state == "not_home" + assert all( + not light.is_on(hass, ent_id) + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "not_home" - assert await async_setup_component( - hass, - "person", - {"person": [{"id": "me", "name": "Me", "device_trackers": [device_1]}]}, - ) + assert await async_setup_component( + hass, + "person", + {"person": [{"id": "me", "name": "Me", "device_trackers": [device_1]}]}, + ) - assert await async_setup_component(hass, "group", {}) - await hass.async_block_till_done() - 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, "group", {}) + await hass.async_block_till_done() + 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, - device_sun_light_trigger.DOMAIN, - {device_sun_light_trigger.DOMAIN: {"device_group": "group.person_me"}}, - ) + assert await async_setup_component( + hass, + device_sun_light_trigger.DOMAIN, + {device_sun_light_trigger.DOMAIN: {"device_group": "group.person_me"}}, + ) - assert all( - hass.states.get(ent_id).state == STATE_OFF - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "not_home" - assert hass.states.get(device_2).state == "not_home" - assert hass.states.get("person.me").state == "not_home" + assert all( + hass.states.get(ent_id).state == STATE_OFF + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "not_home" + assert hass.states.get("person.me").state == "not_home" - # Unrelated device has no impact - hass.states.async_set(device_2, STATE_HOME) - await hass.async_block_till_done() + # Unrelated device has no impact + hass.states.async_set(device_2, STATE_HOME) + await hass.async_block_till_done() - assert all( - hass.states.get(ent_id).state == STATE_OFF - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "not_home" - assert hass.states.get(device_2).state == "home" - assert hass.states.get("person.me").state == "not_home" + assert all( + hass.states.get(ent_id).state == STATE_OFF + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "home" + assert hass.states.get("person.me").state == "not_home" - # person home switches on - hass.states.async_set(device_1, STATE_HOME) - await hass.async_block_till_done() - await hass.async_block_till_done() + # person home switches on + hass.states.async_set(device_1, STATE_HOME) + await hass.async_block_till_done() + await hass.async_block_till_done() - assert all( - hass.states.get(ent_id).state == light.STATE_ON - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "home" - assert hass.states.get(device_2).state == "home" - assert hass.states.get("person.me").state == "home" + assert all( + hass.states.get(ent_id).state == light.STATE_ON + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "home" + assert hass.states.get(device_2).state == "home" + assert hass.states.get("person.me").state == "home" async def test_initialize_start(hass: HomeAssistant) -> None: diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index e55a9b5b6b2cef..49912fd282f8ba 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -259,7 +259,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 # should be disabled assert len(entity_registry.entities) == 3 diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 67bc24909c59c7..024187a33f6a9e 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -3,6 +3,7 @@ import json import logging import os +from types import ModuleType from unittest.mock import Mock, call, patch import pytest @@ -33,6 +34,7 @@ from tests.common import ( assert_setup_component, async_fire_time_changed, + import_and_test_deprecated_constant_enum, mock_registry, mock_restore_cache, patch_yaml_files, @@ -123,7 +125,7 @@ async def test_reading_yaml_config( assert device.config_picture == config.config_picture assert device.consider_home == config.consider_home assert device.icon == config.icon - assert f"{device_tracker.DOMAIN}.test" in hass.config.components + assert f"test.{device_tracker.DOMAIN}" in hass.config.components @patch("homeassistant.components.device_tracker.const.LOGGER.warning") @@ -603,7 +605,7 @@ async def test_bad_platform(hass: HomeAssistant) -> None: with assert_setup_component(0, device_tracker.DOMAIN): assert await async_setup_component(hass, device_tracker.DOMAIN, config) - assert f"{device_tracker.DOMAIN}.bad_platform" not in hass.config.components + assert f"bad_platform.{device_tracker.DOMAIN}" not in hass.config.components async def test_adding_unknown_device_to_config( @@ -681,3 +683,19 @@ def test_see_schema_allowing_ios_calls() -> None: "hostname": "beer", } ) + + +@pytest.mark.parametrize(("enum"), list(SourceType)) +@pytest.mark.parametrize( + "module", + [device_tracker, device_tracker.const], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: SourceType, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, "SOURCE_TYPE_", "2025.1" + ) diff --git a/tests/components/device_tracker/test_legacy.py b/tests/components/device_tracker/test_legacy.py new file mode 100644 index 00000000000000..d7a2f33c23b5c2 --- /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/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr index d2ff64ad59650e..8c069de8f62a8b 100644 --- a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -40,6 +40,7 @@ 'disabled_by': None, 'domain': 'devolo_home_control', 'entry_id': '123456', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 8cf63cf07aea52..9d8faab9b13b3c 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -12,7 +12,7 @@ UpdateFirmwareCheck, WifiGuestAccessGet, ) -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import LOCAL, REMOTE, LogicalNetwork from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -117,14 +117,34 @@ { "mac_address": "AA:BB:CC:DD:EE:FF", "attached_to_router": False, - } + "topology": LOCAL, + "user_device_name": "test1", + }, + { + "mac_address": "11:22:33:44:55:66", + "attached_to_router": True, + "topology": REMOTE, + "user_device_name": "test2", + }, + { + "mac_address": "12:34:56:78:9A:BC", + "attached_to_router": False, + "topology": REMOTE, + "user_device_name": "test3", + }, ], data_rates=[ { "mac_address_from": "AA:BB:CC:DD:EE:FF", "mac_address_to": "11:22:33:44:55:66", - "rx_rate": 0.0, - "tx_rate": 0.0, + "rx_rate": 100.0, + "tx_rate": 100.0, + }, + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "12:34:56:78:9A:BC", + "rx_rate": 150.0, + "tx_rate": 150.0, }, ], ) @@ -136,5 +156,18 @@ "attached_to_router": True, } ], - data_rates=[], + data_rates=[ + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "11:22:33:44:55:66", + "rx_rate": 100.0, + "tx_rate": 100.0, + }, + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "12:34:56:78:9A:BC", + "rx_rate": 150.0, + "tx_rate": 150.0, + }, + ], ) diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr index 236588b87ada6f..317aaac0116bb7 100644 --- a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -24,6 +24,7 @@ 'disabled_by': None, 'domain': 'devolo_home_network', 'entry_id': '123456', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index 88eb46d57e8d3e..4ab4635683ca36 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -134,3 +134,99 @@ 'unit_of_measurement': None, }) # --- +# name: test_update_plc_phyrates + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title PLC downlink PHY rate (test2)', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_update_plc_phyrates.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + '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': 'PLC downlink PHY rate (test2)', + 'platform': 'devolo_home_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plc_rx_rate', + 'unique_id': '1234567890_plc_rx_rate_11:22:33:44:55:66', + 'unit_of_measurement': , + }) +# --- +# name: test_update_plc_phyrates.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title PLC downlink PHY rate (test2)', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_update_plc_phyrates.3 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + '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': 'PLC downlink PHY rate (test2)', + 'platform': 'devolo_home_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plc_rx_rate', + 'unique_id': '1234567890_plc_rx_rate_11:22:33:44:55:66', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 230457f5617389..e6f02033425c8e 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -17,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from . import configure_integration +from .const import PLCNET from .mock import MockDevice from tests.common import async_fire_time_changed @@ -33,6 +34,30 @@ async def test_sensor_setup(hass: HomeAssistant) -> None: assert hass.states.get(f"{DOMAIN}.{device_name}_connected_wifi_clients") is not None assert hass.states.get(f"{DOMAIN}.{device_name}_connected_plc_devices") is None assert hass.states.get(f"{DOMAIN}.{device_name}_neighboring_wifi_networks") is None + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + ) + is not None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + ) + is not None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_downlink_phyrate_{PLCNET.devices[2].user_device_name}" + ) + is None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_uplink_phyrate_{PLCNET.devices[2].user_device_name}" + ) + is None + ) await hass.config_entries.async_unload(entry.entry_id) @@ -100,3 +125,56 @@ async def test_sensor( assert state.state == "1" await hass.config_entries.async_unload(entry.entry_id) + + +async def test_update_plc_phyrates( + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test state change of plc_downlink_phyrate and plc_uplink_phyrate sensor devices.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key_downlink = f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + state_key_uplink = f"{DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(state_key_downlink) == snapshot + assert entity_registry.async_get(state_key_downlink) == snapshot + assert hass.states.get(state_key_downlink) == snapshot + assert entity_registry.async_get(state_key_downlink) == snapshot + + # Emulate device failure + mock_device.plcnet.async_get_network_overview = AsyncMock( + side_effect=DeviceUnavailable + ) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(state_key_downlink) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get(state_key_uplink) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + mock_device.reset() + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(state_key_downlink) + assert state is not None + assert state.state == str(PLCNET.data_rates[0].rx_rate) + + state = hass.states.get(state_key_uplink) + assert state is not None + assert state.state == str(PLCNET.data_rates[0].tx_rate) + + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 47933c3053758b..a63300b1ea2892 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -151,8 +151,11 @@ async def _async_handle_dhcp_packet(packet): 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 @@ -825,6 +828,36 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi assert len(mock_init.mock_calls) == 0 +async def test_device_tracker_invalid_ip_address( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test an invalid ip address.""" + + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + device_tracker_watcher = dhcp.DeviceTrackerWatcher( + hass, + {}, + [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], + ) + await device_tracker_watcher.async_start() + await hass.async_block_till_done() + hass.states.async_set( + "device_tracker.august_connect", + STATE_HOME, + { + ATTR_IP: "invalid", + ATTR_SOURCE_TYPE: SourceType.ROUTER, + ATTR_MAC: "B8:B7:F1:6D:B5:33", + }, + ) + await hass.async_block_till_done() + await device_tracker_watcher.async_stop() + await hass.async_block_till_done() + + assert "Ignoring invalid IP Address: invalid" in caplog.text + assert len(mock_init.mock_calls) == 0 + + async def test_device_tracker_ignore_self_assigned_ips_before_start( hass: HomeAssistant, ) -> None: diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 5dc76a2170e0d0..48b12132cbebc9 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.directv.media_player import ( @@ -305,6 +306,7 @@ async def test_check_attributes( async def test_attributes_paused( hass: HomeAssistant, mock_now: dt_util.dt.datetime, + freezer: FrozenDateTimeFactory, aioclient_mock: AiohttpClientMocker, ) -> None: """Test attributes while paused.""" @@ -315,11 +317,9 @@ async def test_attributes_paused( # Test to make sure that ATTR_MEDIA_POSITION_UPDATED_AT is not # updated if TV is paused. - with patch( - "homeassistant.util.dt.utcnow", return_value=mock_now + timedelta(minutes=5) - ): - await async_media_pause(hass, CLIENT_ENTITY_ID) - await hass.async_block_till_done() + freezer.move_to(mock_now + timedelta(minutes=5)) + await async_media_pause(hass, CLIENT_ENTITY_ID) + await hass.async_block_till_done() state = hass.states.get(CLIENT_ENTITY_ID) assert state.state == STATE_PAUSED diff --git a/tests/components/discovergy/conftest.py b/tests/components/discovergy/conftest.py index ea0fe84852fa34..819a1cbb72ab5f 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(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 +@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 5c233d50ba84d6..5e596d7970f5ca 100644 --- a/tests/components/discovergy/const.py +++ b/tests/components/discovergy/const.py @@ -30,6 +30,32 @@ "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( @@ -41,7 +67,7 @@ "energyOut": 55048723044000.0, "energyOut1": 0.0, "energyOut2": 0.0, - "power": 531750.0, + "power": 0.0, "power1": 142680.0, "power2": 138010.0, "power3": 251060.0, @@ -50,3 +76,8 @@ "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 d02f57c75403b0..e8d4eab1909b61 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({ @@ -33,7 +61,7 @@ 'energyOut': 55048723044000.0, 'energyOut1': 0.0, 'energyOut2': 0.0, - 'power': 531750.0, + 'power': 0.0, 'power1': 142680.0, 'power2': 138010.0, 'power3': 251060.0, diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..2473af5012a03f --- /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': '0.0', + }) +# --- +# 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 08e9df06978fc1..7c257f814c4fd4 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.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} - - 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.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", + }, + ) - 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 d7565e3f0c438c..f2db5fb854d485 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 00000000000000..ac8f79540f5474 --- /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 00000000000000..aba8229acf5c11 --- /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/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index 1282cddc5e6b8f..6fd24ad9b13b49 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import patch from aiodns.error import DNSError +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.dnsip.const import ( CONF_HOSTNAME, @@ -14,10 +15,10 @@ CONF_RESOLVER_IPV6, DOMAIN, ) +from homeassistant.components.dnsip.sensor import SCAN_INTERVAL from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from . import RetrieveDNS @@ -58,7 +59,9 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state2.state == "1.2.3.4" -async def test_sensor_no_response(hass: HomeAssistant) -> None: +async def test_sensor_no_response( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the DNS IP sensor with DNS error.""" entry = MockConfigEntry( domain=DOMAIN, @@ -95,10 +98,18 @@ async def test_sensor_no_response(hass: HomeAssistant) -> None: "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", return_value=dns_mock, ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=10), - ) + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Allows 2 retries before going unavailable + state = hass.states.get("sensor.home_assistant_io") + assert state.state == "1.2.3.4" + + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.home_assistant_io") diff --git a/tests/components/drop_connect/__init__.py b/tests/components/drop_connect/__init__.py new file mode 100644 index 00000000000000..f67b77b906e5dc --- /dev/null +++ b/tests/components/drop_connect/__init__.py @@ -0,0 +1 @@ +"""Tests for the DROP integration.""" diff --git a/tests/components/drop_connect/common.py b/tests/components/drop_connect/common.py new file mode 100644 index 00000000000000..2e4d59fe7b2053 --- /dev/null +++ b/tests/components/drop_connect/common.py @@ -0,0 +1,218 @@ +"""Define common test values.""" + +from homeassistant.components.drop_connect.const import ( + CONF_COMMAND_TOPIC, + CONF_DATA_TOPIC, + CONF_DEVICE_DESC, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_DEVICE_OWNER_ID, + CONF_DEVICE_TYPE, + CONF_HUB_ID, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry + +from tests.common import MockConfigEntry + +TEST_DATA_HUB_TOPIC = "drop_connect/DROP-1_C0FFEE/255" +TEST_DATA_HUB = ( + '{"curFlow":5.77,"peakFlow":13.8,"usedToday":232.77,"avgUsed":76,"psi":62.2,"psiLow":61,"psiHigh":62,' + '"water":1,"bypass":0,"pMode":"home","battery":50,"notif":1,"leak":0}' +) +TEST_DATA_HUB_RESET = ( + '{"curFlow":0,"peakFlow":0,"usedToday":0,"avgUsed":0,"psi":0,"psiLow":0,"psiHigh":0,' + '"water":0,"bypass":1,"pMode":"away","battery":0,"notif":0,"leak":0}' +) + +TEST_DATA_SALT_TOPIC = "drop_connect/DROP-1_C0FFEE/8" +TEST_DATA_SALT = '{"salt":1}' +TEST_DATA_SALT_RESET = '{"salt":0}' + +TEST_DATA_LEAK_TOPIC = "drop_connect/DROP-1_C0FFEE/20" +TEST_DATA_LEAK = '{"battery":100,"leak":1,"temp":68.2}' +TEST_DATA_LEAK_RESET = '{"battery":0,"leak":0,"temp":0}' + +TEST_DATA_SOFTENER_TOPIC = "drop_connect/DROP-1_C0FFEE/0" +TEST_DATA_SOFTENER = ( + '{"curFlow":5.0,"bypass":0,"battery":20,"capacity":1000,"resInUse":1,"psi":50.5}' +) +TEST_DATA_SOFTENER_RESET = ( + '{"curFlow":0,"bypass":1,"battery":0,"capacity":0,"resInUse":0,"psi":null}' +) + +TEST_DATA_FILTER_TOPIC = "drop_connect/DROP-1_C0FFEE/4" +TEST_DATA_FILTER = '{"curFlow":19.84,"bypass":0,"battery":12,"psi":38.2}' +TEST_DATA_FILTER_RESET = '{"curFlow":0,"bypass":1,"battery":0,"psi":null}' + +TEST_DATA_PROTECTION_VALVE_TOPIC = "drop_connect/DROP-1_C0FFEE/78" +TEST_DATA_PROTECTION_VALVE = ( + '{"curFlow":7.1,"psi":61.3,"water":1,"battery":0,"leak":1,"temp":70.5}' +) +TEST_DATA_PROTECTION_VALVE_RESET = ( + '{"curFlow":0,"psi":0,"water":0,"battery":0,"leak":0,"temp":0}' +) + +TEST_DATA_PUMP_CONTROLLER_TOPIC = "drop_connect/DROP-1_C0FFEE/83" +TEST_DATA_PUMP_CONTROLLER = '{"curFlow":2.2,"psi":62.2,"pump":1,"leak":1,"temp":68.8}' +TEST_DATA_PUMP_CONTROLLER_RESET = '{"curFlow":0,"psi":0,"pump":0,"leak":0,"temp":0}' + +TEST_DATA_RO_FILTER_TOPIC = "drop_connect/DROP-1_C0FFEE/95" +TEST_DATA_RO_FILTER = ( + '{"leak":1,"tdsIn":164,"tdsOut":9,"cart1":59,"cart2":80,"cart3":59}' +) +TEST_DATA_RO_FILTER_RESET = ( + '{"leak":0,"tdsIn":0,"tdsOut":0,"cart1":0,"cart2":0,"cart3":0}' +) + + +def config_entry_hub() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_255", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/255/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/255/#", + CONF_DEVICE_DESC: "Hub", + CONF_DEVICE_ID: 255, + CONF_DEVICE_NAME: "Hub DROP-1_C0FFEE", + CONF_DEVICE_TYPE: "hub", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_salt() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_8", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/8/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/8/#", + CONF_DEVICE_DESC: "Salt Sensor", + CONF_DEVICE_ID: 8, + CONF_DEVICE_NAME: "Salt Sensor", + CONF_DEVICE_TYPE: "salt", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_leak() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_20", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/20/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/20/#", + CONF_DEVICE_DESC: "Leak Detector", + CONF_DEVICE_ID: 20, + CONF_DEVICE_NAME: "Leak Detector", + CONF_DEVICE_TYPE: "leak", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_softener() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_0", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/0/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/0/#", + CONF_DEVICE_DESC: "Softener", + CONF_DEVICE_ID: 0, + CONF_DEVICE_NAME: "Softener", + CONF_DEVICE_TYPE: "soft", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_filter() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_4", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/4/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/4/#", + CONF_DEVICE_DESC: "Filter", + CONF_DEVICE_ID: 4, + CONF_DEVICE_NAME: "Filter", + CONF_DEVICE_TYPE: "filt", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_protection_valve() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_78", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/78/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/78/#", + CONF_DEVICE_DESC: "Protection Valve", + CONF_DEVICE_ID: 78, + CONF_DEVICE_NAME: "Protection Valve", + CONF_DEVICE_TYPE: "pv", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_pump_controller() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_83", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/83/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/83/#", + CONF_DEVICE_DESC: "Pump Controller", + CONF_DEVICE_ID: 83, + CONF_DEVICE_NAME: "Pump Controller", + CONF_DEVICE_TYPE: "pc", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_ro_filter() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_255", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/95/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/95/#", + CONF_DEVICE_DESC: "RO Filter", + CONF_DEVICE_ID: 95, + CONF_DEVICE_NAME: "RO Filter", + CONF_DEVICE_TYPE: "ro", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) diff --git a/tests/components/drop_connect/test_binary_sensor.py b/tests/components/drop_connect/test_binary_sensor.py new file mode 100644 index 00000000000000..2f54e8fb791567 --- /dev/null +++ b/tests/components/drop_connect/test_binary_sensor.py @@ -0,0 +1,196 @@ +"""Test DROP binary sensor entities.""" + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from .common import ( + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + TEST_DATA_LEAK, + TEST_DATA_LEAK_RESET, + TEST_DATA_LEAK_TOPIC, + TEST_DATA_PROTECTION_VALVE, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_PUMP_CONTROLLER, + TEST_DATA_PUMP_CONTROLLER_RESET, + TEST_DATA_PUMP_CONTROLLER_TOPIC, + TEST_DATA_RO_FILTER, + TEST_DATA_RO_FILTER_RESET, + TEST_DATA_RO_FILTER_TOPIC, + TEST_DATA_SALT, + TEST_DATA_SALT_RESET, + TEST_DATA_SALT_TOPIC, + TEST_DATA_SOFTENER, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER_TOPIC, + config_entry_hub, + config_entry_leak, + config_entry_protection_valve, + config_entry_pump_controller, + config_entry_ro_filter, + config_entry_salt, + config_entry_softener, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_binary_sensors_hub( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for hubs.""" + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + pending_notifications_sensor_name = ( + "binary_sensor.hub_drop_1_c0ffee_notification_unread" + ) + assert hass.states.get(pending_notifications_sensor_name).state == STATE_OFF + leak_sensor_name = "binary_sensor.hub_drop_1_c0ffee_leak_detected" + assert hass.states.get(leak_sensor_name).state == STATE_OFF + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + assert hass.states.get(pending_notifications_sensor_name).state == STATE_OFF + assert hass.states.get(leak_sensor_name).state == STATE_OFF + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + assert hass.states.get(pending_notifications_sensor_name).state == STATE_ON + assert hass.states.get(leak_sensor_name).state == STATE_OFF + + +async def test_binary_sensors_salt( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for salt sensors.""" + entry = config_entry_salt() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + salt_sensor_name = "binary_sensor.salt_sensor_salt_low" + assert hass.states.get(salt_sensor_name).state == STATE_OFF + + async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT_RESET) + await hass.async_block_till_done() + assert hass.states.get(salt_sensor_name).state == STATE_OFF + + async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT) + await hass.async_block_till_done() + assert hass.states.get(salt_sensor_name).state == STATE_ON + + +async def test_binary_sensors_leak( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for leak detectors.""" + entry = config_entry_leak() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + leak_sensor_name = "binary_sensor.leak_detector_leak_detected" + assert hass.states.get(leak_sensor_name).state == STATE_OFF + + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK_RESET) + await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_OFF + + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK) + await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_ON + + +async def test_binary_sensors_softener( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for softeners.""" + entry = config_entry_softener() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + reserve_in_use_sensor_name = "binary_sensor.softener_reserve_capacity_in_use" + assert hass.states.get(reserve_in_use_sensor_name).state == STATE_OFF + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + assert hass.states.get(reserve_in_use_sensor_name).state == STATE_OFF + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + assert hass.states.get(reserve_in_use_sensor_name).state == STATE_ON + + +async def test_binary_sensors_protection_valve( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for protection valves.""" + entry = config_entry_protection_valve() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + leak_sensor_name = "binary_sensor.protection_valve_leak_detected" + assert hass.states.get(leak_sensor_name).state == STATE_OFF + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_OFF + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_ON + + +async def test_binary_sensors_pump_controller( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for pump controllers.""" + entry = config_entry_pump_controller() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + leak_sensor_name = "binary_sensor.pump_controller_leak_detected" + assert hass.states.get(leak_sensor_name).state == STATE_OFF + pump_sensor_name = "binary_sensor.pump_controller_pump_status" + assert hass.states.get(pump_sensor_name).state == STATE_OFF + + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER_RESET + ) + await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_OFF + assert hass.states.get(pump_sensor_name).state == STATE_OFF + + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER + ) + await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_ON + assert hass.states.get(pump_sensor_name).state == STATE_ON + + +async def test_binary_sensors_ro_filter( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for RO filters.""" + entry = config_entry_ro_filter() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + leak_sensor_name = "binary_sensor.ro_filter_leak_detected" + assert hass.states.get(leak_sensor_name).state == STATE_OFF + + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER_RESET) + await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_OFF + + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER) + await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_ON diff --git a/tests/components/drop_connect/test_config_flow.py b/tests/components/drop_connect/test_config_flow.py new file mode 100644 index 00000000000000..fb727d2c7fdb25 --- /dev/null +++ b/tests/components/drop_connect/test_config_flow.py @@ -0,0 +1,178 @@ +"""Test config flow.""" +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from tests.typing import MqttMockHAClient + + +async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "drop_command_topic": "drop_connect/DROP-1_C0FFEE/cmd/255", + "drop_data_topic": "drop_connect/DROP-1_C0FFEE/data/255/#", + "device_desc": "Hub", + "device_id": "255", + "name": "Hub DROP-1_C0FFEE", + "device_type": "hub", + "drop_hub_id": "DROP-1_C0FFEE", + "drop_device_owner_id": "DROP-1_C0FFEE_255", + } + + +async def test_duplicate(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + + # Attempting configuration of the same object should abort + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_incomplete_payload( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_bad_json( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload="{BAD JSON}", + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_bad_topic( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/FOO", + payload=('{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}'), + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_no_payload( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload="", + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_user_setup(hass: HomeAssistant) -> None: + """Test user setup.""" + result = await hass.config_entries.flow.async_init( + "drop_connect", context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "abort" + assert result["reason"] == "not_supported" diff --git a/tests/components/drop_connect/test_init.py b/tests/components/drop_connect/test_init.py new file mode 100644 index 00000000000000..4963119b349a0e --- /dev/null +++ b/tests/components/drop_connect/test_init.py @@ -0,0 +1,66 @@ +"""Test DROP initialisation.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import ( + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + config_entry_hub, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_bad_json(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test bad JSON.""" + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, "{BAD JSON}") + await hass.async_block_till_done() + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN + + +async def test_unload(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test entity unload.""" + # Load the hub device + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + assert hass.states.get(current_flow_sensor_name).state == "0.0" + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + assert hass.states.get(current_flow_sensor_name).state == "5.77" + + # Unload the device + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + + # Verify sensor is unavailable + assert hass.states.get(current_flow_sensor_name).state == STATE_UNAVAILABLE + + +async def test_no_mqtt(hass: HomeAssistant) -> None: + """Test no MQTT.""" + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) is False + + protect_mode_select_name = "select.hub_drop_1_c0ffee_protect_mode" + assert hass.states.get(protect_mode_select_name) is None diff --git a/tests/components/drop_connect/test_select.py b/tests/components/drop_connect/test_select.py new file mode 100644 index 00000000000000..1e00f6031d4034 --- /dev/null +++ b/tests/components/drop_connect/test_select.py @@ -0,0 +1,71 @@ +"""Test DROP select entities.""" + +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import ( + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + config_entry_hub, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_selects_hub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test DROP binary sensors for hubs.""" + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + protect_mode_select_name = "select.hub_drop_1_c0ffee_protect_mode" + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.attributes.get(ATTR_OPTIONS) == [ + "away", + "home", + "schedule", + ] + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.attributes.get(ATTR_OPTIONS) == [ + "away", + "home", + "schedule", + ] + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.state == "home" + + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "away", ATTR_ENTITY_ID: protect_mode_select_name}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response of the device + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.state == "away" diff --git a/tests/components/drop_connect/test_sensor.py b/tests/components/drop_connect/test_sensor.py new file mode 100644 index 00000000000000..43da49af884a4e --- /dev/null +++ b/tests/components/drop_connect/test_sensor.py @@ -0,0 +1,319 @@ +"""Test DROP sensor entities.""" + +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import ( + TEST_DATA_FILTER, + TEST_DATA_FILTER_RESET, + TEST_DATA_FILTER_TOPIC, + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + TEST_DATA_LEAK, + TEST_DATA_LEAK_RESET, + TEST_DATA_LEAK_TOPIC, + TEST_DATA_PROTECTION_VALVE, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_PUMP_CONTROLLER, + TEST_DATA_PUMP_CONTROLLER_RESET, + TEST_DATA_PUMP_CONTROLLER_TOPIC, + TEST_DATA_RO_FILTER, + TEST_DATA_RO_FILTER_RESET, + TEST_DATA_RO_FILTER_TOPIC, + TEST_DATA_SOFTENER, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER_TOPIC, + config_entry_filter, + config_entry_hub, + config_entry_leak, + config_entry_protection_valve, + config_entry_pump_controller, + config_entry_ro_filter, + config_entry_softener, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_sensors_hub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test DROP sensors for hubs.""" + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN + peak_flow_sensor_name = "sensor.hub_drop_1_c0ffee_peak_water_flow_rate_today" + assert hass.states.get(peak_flow_sensor_name).state == STATE_UNKNOWN + used_today_sensor_name = "sensor.hub_drop_1_c0ffee_total_water_used_today" + assert hass.states.get(used_today_sensor_name).state == STATE_UNKNOWN + average_usage_sensor_name = "sensor.hub_drop_1_c0ffee_average_daily_water_usage" + assert hass.states.get(average_usage_sensor_name).state == STATE_UNKNOWN + psi_sensor_name = "sensor.hub_drop_1_c0ffee_current_water_pressure" + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN + psi_high_sensor_name = "sensor.hub_drop_1_c0ffee_high_water_pressure_today" + assert hass.states.get(psi_high_sensor_name).state == STATE_UNKNOWN + psi_low_sensor_name = "sensor.hub_drop_1_c0ffee_low_water_pressure_today" + assert hass.states.get(psi_low_sensor_name).state == STATE_UNKNOWN + battery_sensor_name = "sensor.hub_drop_1_c0ffee_battery" + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == "5.77" + + peak_flow_sensor = hass.states.get(peak_flow_sensor_name) + assert peak_flow_sensor + assert peak_flow_sensor.state == "13.8" + + used_today_sensor = hass.states.get(used_today_sensor_name) + assert used_today_sensor + assert used_today_sensor.state == "881.13030096168" # liters + + average_usage_sensor = hass.states.get(average_usage_sensor_name) + assert average_usage_sensor + assert average_usage_sensor.state == "287.691295584" # liters + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert psi_sensor.state == "428.8538854" # centibars + + psi_high_sensor = hass.states.get(psi_high_sensor_name) + assert psi_high_sensor + assert psi_high_sensor.state == "427.474934" # centibars + + psi_low_sensor = hass.states.get(psi_low_sensor_name) + assert psi_low_sensor + assert psi_low_sensor.state == "420.580177" # centibars + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert battery_sensor.state == "50" + + +async def test_sensors_leak(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test DROP sensors for leak detectors.""" + entry = config_entry_leak() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + battery_sensor_name = "sensor.leak_detector_battery" + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN + temp_sensor_name = "sensor.leak_detector_temperature" + assert hass.states.get(temp_sensor_name).state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert battery_sensor.state == "100" + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert temp_sensor.state == "20.1111111111111" # °C + + +async def test_sensors_softener( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for softeners.""" + entry = config_entry_softener() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + battery_sensor_name = "sensor.softener_battery" + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN + current_flow_sensor_name = "sensor.softener_water_flow_rate" + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN + psi_sensor_name = "sensor.softener_current_water_pressure" + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN + capacity_sensor_name = "sensor.softener_capacity_remaining" + assert hass.states.get(capacity_sensor_name).state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert battery_sensor.state == "20" + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == "5.0" + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert psi_sensor.state == "348.1852285" # centibars + + capacity_sensor = hass.states.get(capacity_sensor_name) + assert capacity_sensor + assert capacity_sensor.state == "3785.411784" # liters + + +async def test_sensors_filter(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test DROP sensors for filters.""" + entry = config_entry_filter() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + battery_sensor_name = "sensor.filter_battery" + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN + current_flow_sensor_name = "sensor.filter_water_flow_rate" + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN + psi_sensor_name = "sensor.filter_current_water_pressure" + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert battery_sensor.state == "12" + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == "19.84" + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert psi_sensor.state == "263.3797174" # centibars + + +async def test_sensors_protection_valve( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for protection valves.""" + entry = config_entry_protection_valve() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + battery_sensor_name = "sensor.protection_valve_battery" + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN + current_flow_sensor_name = "sensor.protection_valve_water_flow_rate" + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN + psi_sensor_name = "sensor.protection_valve_current_water_pressure" + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN + temp_sensor_name = "sensor.protection_valve_temperature" + assert hass.states.get(temp_sensor_name).state == STATE_UNKNOWN + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert battery_sensor.state == "0" + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == "7.1" + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert psi_sensor.state == "422.6486041" # centibars + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert temp_sensor.state == "21.3888888888889" # °C + + +async def test_sensors_pump_controller( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for pump controllers.""" + entry = config_entry_pump_controller() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + current_flow_sensor_name = "sensor.pump_controller_water_flow_rate" + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN + psi_sensor_name = "sensor.pump_controller_current_water_pressure" + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN + temp_sensor_name = "sensor.pump_controller_temperature" + assert hass.states.get(temp_sensor_name).state == STATE_UNKNOWN + + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER + ) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == "2.2" + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert psi_sensor.state == "428.8538854" # centibars + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert temp_sensor.state == "20.4444444444444" # °C + + +async def test_sensors_ro_filter( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for RO filters.""" + entry = config_entry_ro_filter() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + tds_in_sensor_name = "sensor.ro_filter_inlet_tds" + assert hass.states.get(tds_in_sensor_name).state == STATE_UNKNOWN + tds_out_sensor_name = "sensor.ro_filter_outlet_tds" + assert hass.states.get(tds_out_sensor_name).state == STATE_UNKNOWN + cart1_sensor_name = "sensor.ro_filter_cartridge_1_life_remaining" + assert hass.states.get(cart1_sensor_name).state == STATE_UNKNOWN + cart2_sensor_name = "sensor.ro_filter_cartridge_2_life_remaining" + assert hass.states.get(cart2_sensor_name).state == STATE_UNKNOWN + cart3_sensor_name = "sensor.ro_filter_cartridge_3_life_remaining" + assert hass.states.get(cart3_sensor_name).state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER) + await hass.async_block_till_done() + + tds_in_sensor = hass.states.get(tds_in_sensor_name) + assert tds_in_sensor + assert tds_in_sensor.state == "164" + + tds_out_sensor = hass.states.get(tds_out_sensor_name) + assert tds_out_sensor + assert tds_out_sensor.state == "9" + + cart1_sensor = hass.states.get(cart1_sensor_name) + assert cart1_sensor + assert cart1_sensor.state == "59" + + cart2_sensor = hass.states.get(cart2_sensor_name) + assert cart2_sensor + assert cart2_sensor.state == "80" + + cart3_sensor = hass.states.get(cart3_sensor_name) + assert cart3_sensor + assert cart3_sensor.state == "59" diff --git a/tests/components/drop_connect/test_switch.py b/tests/components/drop_connect/test_switch.py new file mode 100644 index 00000000000000..0e244e9ab5908c --- /dev/null +++ b/tests/components/drop_connect/test_switch.py @@ -0,0 +1,271 @@ +"""Test DROP switch entities.""" + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import ( + TEST_DATA_FILTER, + TEST_DATA_FILTER_RESET, + TEST_DATA_FILTER_TOPIC, + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + TEST_DATA_PROTECTION_VALVE, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_SOFTENER, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER_TOPIC, + config_entry_filter, + config_entry_hub, + config_entry_protection_valve, + config_entry_softener, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_switches_hub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test DROP switches for hubs.""" + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + water_supply_switch_name = "switch.hub_drop_1_c0ffee_water_supply" + assert hass.states.get(water_supply_switch_name).state == STATE_UNKNOWN + bypass_switch_name = "switch.hub_drop_1_c0ffee_treatment_bypass" + assert hass.states.get(bypass_switch_name).state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + assert hass.states.get(water_supply_switch_name).state == STATE_OFF + assert hass.states.get(bypass_switch_name).state == STATE_ON + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + assert hass.states.get(water_supply_switch_name).state == STATE_ON + assert hass.states.get(bypass_switch_name).state == STATE_OFF + + # Test switch turn off method. + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response from the hub + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + assert hass.states.get(water_supply_switch_name).state == STATE_OFF + + # Test switch turn on method. + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response from the hub + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + assert hass.states.get(water_supply_switch_name).state == STATE_ON + + # Test switch turn on method. + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_ON + + # Test switch turn off method. + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_OFF + + +async def test_switches_protection_valve( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for protection valves.""" + entry = config_entry_protection_valve() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + water_supply_switch_name = "switch.protection_valve_water_supply" + assert hass.states.get(water_supply_switch_name).state == STATE_UNKNOWN + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + assert hass.states.get(water_supply_switch_name).state == STATE_OFF + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + assert hass.states.get(water_supply_switch_name).state == STATE_ON + + # Test switch turn off method. + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response from the device + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + assert hass.states.get(water_supply_switch_name).state == STATE_OFF + + # Test switch turn on method. + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response from the device + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + assert hass.states.get(water_supply_switch_name).state == STATE_ON + + +async def test_switches_softener( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for softeners.""" + entry = config_entry_softener() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + bypass_switch_name = "switch.softener_treatment_bypass" + assert hass.states.get(bypass_switch_name).state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_ON + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_OFF + + # Test switch turn on method. + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_ON + + # Test switch turn off method. + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_OFF + + +async def test_switches_filter( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for filters.""" + entry = config_entry_filter() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + bypass_switch_name = "switch.filter_treatment_bypass" + assert hass.states.get(bypass_switch_name).state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) + await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_ON + + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) + await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_OFF + + # Test switch turn on method. + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) + await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_ON + + # Test switch turn off method. + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) + await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_OFF diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 55395b922707eb..422bfa0c35cb44 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -335,7 +335,7 @@ async def test_setup_serial_fail( # override the mock to have it fail the first time and succeed after first_fail_connection_factory = AsyncMock( return_value=(transport, protocol), - side_effect=chain([serial.serialutil.SerialException], repeat(DEFAULT)), + side_effect=chain([serial.SerialException], repeat(DEFAULT)), ) assert result["type"] == "form" @@ -474,8 +474,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry_data = { "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 512e0822016430..b42f26f4ccc351 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -98,8 +98,6 @@ async def test_migrate_unique_id( data={ "port": "/dev/ttyUSB0", "dsmr_version": dsmr_version, - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py new file mode 100644 index 00000000000000..5e31fa7a82e592 --- /dev/null +++ b/tests/components/dsmr/test_mbus_migration.py @@ -0,0 +1,208 @@ +"""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", + "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", + "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 1895dd15dd175f..419b562f431a67 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -8,19 +8,8 @@ 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 @@ ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -61,8 +51,6 @@ async def test_default_setup( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -145,8 +133,8 @@ async def test_default_setup( # 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") @@ -199,8 +187,6 @@ async def test_setup_only_energy( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", } entry_options = { @@ -255,8 +241,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "4", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -321,7 +305,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 @@ -334,8 +328,6 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -348,7 +340,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 +376,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) @@ -410,8 +402,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5L", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -495,10 +485,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 @@ -506,16 +504,32 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No 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 = { + BELGIUM_CURRENT_AVERAGE_DEMAND: CosemObject( + BELGIUM_CURRENT_AVERAGE_DEMAND, + [{"value": Decimal(1.75), "unit": "kW"}], + ), + BELGIUM_MAXIMUM_DEMAND_MONTH: MBusObject( + BELGIUM_MAXIMUM_DEMAND_MONTH, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"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, [ @@ -523,36 +537,46 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No {"value": Decimal(745.695), "unit": "m3"}, ], ), - BELGIUM_MBUS2_METER_READING2: MBusObject( - BELGIUM_MBUS2_METER_READING2, + 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(745.696), "unit": "m3"}, + {"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(745.697), "unit": "m3"}, + {"value": Decimal(12.12), "unit": "m3"}, ], ), - BELGIUM_MBUS4_METER_READING2: MBusObject( - BELGIUM_MBUS4_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(745.698), "unit": "m3"}, - ], + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "007", "unit": ""}] ), - BELGIUM_CURRENT_AVERAGE_DEMAND: CosemObject( - BELGIUM_CURRENT_AVERAGE_DEMAND, - [{"value": Decimal(1.75), "unit": "kW"}], + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373334", "unit": ""}], ), - BELGIUM_MAXIMUM_DEMAND_MONTH: MBusObject( - BELGIUM_MAXIMUM_DEMAND_MONTH, + BELGIUM_MBUS4_METER_READING1: MBusObject( + BELGIUM_MBUS4_METER_READING1, [ - {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(4.11), "unit": "kW"}, + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(13.13), "unit": "m3"}, ], ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( @@ -600,7 +624,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 +637,281 @@ 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"), - [ - ( + # 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.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", + "serial_id": "1234", + "serial_id_gas": None, + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING1: MBusObject( BELGIUM_MBUS1_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642215)}, + {"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, - BELGIUM_MBUS3_METER_READING1, - "745.696", + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(678.901), "unit": "m3"}, + ], ), - ( - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - "745.695", + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "007", "unit": ""}] ), - ( - BELGIUM_MBUS4_METER_READING2, - BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING1: MBusObject( BELGIUM_MBUS3_METER_READING1, - "745.695", + [ + {"value": datetime.datetime.fromtimestamp(1551642217)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], ), - ( - BELGIUM_MBUS4_METER_READING1, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - "745.697", + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "003", "unit": ""}] ), - ], -) -async def test_belgian_meter_alt( - hass: HomeAssistant, - dsmr_connection_fixture, - key1: Literal, - key2: Literal, - key3: Literal, - gas_value: str, -) -> None: + 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"}, + ], + ), + } + + 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() + + # 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 == "678.901" + 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 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.objects import MBusObject + 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, - "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"}, - ], + 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": ""}], ), - key2: MBusObject( - key2, + 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(1551642214)}, - {"value": Decimal(745.696), "unit": "m3"}, + {"value": datetime.datetime.fromtimestamp(1551642217)}, + {"value": Decimal(12.12), "unit": "m3"}, ], ), - key3: MBusObject( - key3, + 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(1551642215)}, - {"value": Decimal(745.697), "unit": "m3"}, + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(13.13), "unit": "m3"}, ], ), } @@ -709,16 +933,34 @@ 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 + # 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.state == gas_value - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + 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 ( - gas_consumption.attributes.get(ATTR_STATE_CLASS) + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING ) assert ( - gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS ) @@ -733,8 +975,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -788,8 +1028,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5S", - "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -863,8 +1101,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", - "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -904,7 +1140,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") - assert active_tariff.state == "54184.6316" + assert active_tariff.state == "54184.632" assert active_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert active_tariff.attributes.get(ATTR_ICON) is None assert ( @@ -917,7 +1153,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) active_tariff = hass.states.get("sensor.electricity_meter_energy_production_total") - assert active_tariff.state == "19981.1069" + assert active_tariff.state == "19981.107" assert ( active_tariff.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING @@ -937,8 +1173,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "1234", "dsmr_version": "2.2", "protocol": "dsmr_protocol", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -965,8 +1199,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "port": "1234", "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -984,6 +1216,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: @@ -993,8 +1226,6 @@ async def test_connection_errors_retry( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1023,6 +1254,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 ( @@ -1036,8 +1268,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1119,8 +1349,6 @@ async def test_gas_meter_providing_energy_reading( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } diff --git a/tests/components/easyenergy/snapshots/test_services.ambr b/tests/components/easyenergy/snapshots/test_services.ambr new file mode 100644 index 00000000000000..96b1eca5498a14 --- /dev/null +++ b/tests/components/easyenergy/snapshots/test_services.ambr @@ -0,0 +1,5590 @@ +# serializer version: 1 +# name: test_service[end0-start0-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start2-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat0-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat0-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat0-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat1-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat1-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat1-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start0-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start1-incl_vat0-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat0-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat0-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat1-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat1-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat1-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start2-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- diff --git a/tests/components/easyenergy/test_services.py b/tests/components/easyenergy/test_services.py new file mode 100644 index 00000000000000..603768237f1f8b --- /dev/null +++ b/tests/components/easyenergy/test_services.py @@ -0,0 +1,151 @@ +"""Tests for the services provided by the easyEnergy integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components.easyenergy.const import DOMAIN +from homeassistant.components.easyenergy.services import ( + ATTR_CONFIG_ENTRY, + ENERGY_RETURN_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + GAS_SERVICE_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("init_integration") +async def test_has_services( + hass: HomeAssistant, +) -> None: + """Test the existence of the easyEnergy Service.""" + assert hass.services.has_service(DOMAIN, GAS_SERVICE_NAME) + assert hass.services.has_service(DOMAIN, ENERGY_USAGE_SERVICE_NAME) + assert hass.services.has_service(DOMAIN, ENERGY_RETURN_SERVICE_NAME) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "service", + [ + GAS_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + ENERGY_RETURN_SERVICE_NAME, + ], +) +@pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}]) +@pytest.mark.parametrize("start", [{"start": "2023-01-01 00:00:00"}, {}]) +@pytest.mark.parametrize("end", [{"end": "2023-01-01 00:00:00"}, {}]) +async def test_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + service: str, + incl_vat: dict[str, bool], + start: dict[str, str], + end: dict[str, str], +) -> None: + """Test the EnergyZero Service.""" + entry = {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} + + data = entry | incl_vat | start | end + + assert snapshot == await hass.services.async_call( + DOMAIN, + service, + data, + blocking=True, + return_response=True, + ) + + +@pytest.fixture +def config_entry_data( + mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest +) -> dict[str, str]: + """Fixture for the config entry.""" + if "config_entry" in request.param and request.param["config_entry"] is True: + return {"config_entry": mock_config_entry.entry_id} + + return request.param + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "service", + [ + GAS_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + ENERGY_RETURN_SERVICE_NAME, + ], +) +@pytest.mark.parametrize( + ("config_entry_data", "service_data", "error", "error_message"), + [ + ({}, {}, vol.er.Error, "required key not provided .+"), + ( + {"config_entry": True}, + {}, + vol.er.Error, + "required key not provided .+", + ), + ( + {}, + {"incl_vat": True}, + vol.er.Error, + "required key not provided .+", + ), + ( + {"config_entry": True}, + {"incl_vat": "incorrect vat"}, + vol.er.Error, + "expected bool for dictionary value .+", + ), + ( + {"config_entry": "incorrect entry"}, + {"incl_vat": True}, + ServiceValidationError, + "Invalid config entry.+", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "start": "incorrect date", + }, + ServiceValidationError, + "Invalid datetime provided.", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "end": "incorrect date", + }, + ServiceValidationError, + "Invalid datetime provided.", + ), + ], + indirect=["config_entry_data"], +) +async def test_service_validation( + hass: HomeAssistant, + service: str, + config_entry_data: dict[str, str], + service_data: dict[str, str | bool], + error: type[Exception], + error_message: str, +) -> None: + """Test the easyEnergy Service.""" + + with pytest.raises(error, match=error_message): + await hass.services.async_call( + DOMAIN, + service, + config_entry_data | service_data, + blocking=True, + return_response=True, + ) diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index 7d79a10e912972..a0f34e3cd2119b 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_sensor.py b/tests/components/efergy/test_sensor.py index 45261d45933643..afeb5f6e382a2f 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -1,7 +1,8 @@ """The tests for Efergy sensor platform.""" from datetime import timedelta -from homeassistant.components.efergy.sensor import SENSOR_TYPES +import pytest + from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, @@ -25,15 +26,18 @@ from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default): + """Make sure all entities are enabled.""" + + async def test_sensor_readings( 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) + await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) state = hass.states.get("sensor.efergy_power_usage") assert state.state == "1580" @@ -85,11 +89,6 @@ 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 = entity_registry.async_get("sensor.efergy_power_usage_728386") - assert entity.disabled_by is er.RegistryEntryDisabler.INTEGRATION - 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") assert state.state == "1628" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER @@ -101,8 +100,6 @@ async def test_multi_sensor_readings( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test for multiple sensors in one household.""" - for description in SENSOR_TYPES: - description.entity_registry_enabled_default = True await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN, MULTI_SENSOR_TOKEN) state = hass.states.get("sensor.efergy_power_usage_728386") assert state.state == "218" diff --git a/tests/components/electrasmart/test_config_flow.py b/tests/components/electrasmart/test_config_flow.py index f53bea3e96c79b..929259a0ccf794 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/snapshots/test_config_flow.ambr b/tests/components/elgato/snapshots/test_config_flow.ambr index 46180994e61687..39202d383fa026 100644 --- a/tests/components/elgato/snapshots/test_config_flow.ambr +++ b/tests/components/elgato/snapshots/test_config_flow.ambr @@ -14,6 +14,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'elgato', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -25,6 +26,7 @@ 'disabled_by': None, 'domain': 'elgato', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -55,6 +57,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'elgato', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -66,6 +69,7 @@ 'disabled_by': None, 'domain': 'elgato', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -95,6 +99,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'elgato', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -106,6 +111,7 @@ 'disabled_by': None, 'domain': 'elgato', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index 216fc019778d96..5e33a8aa4c37de 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/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index fb5ff265497409..167562578f2a04 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -95,6 +95,8 @@ "24": "media_player.kitchen", "25": "light.office_rgbw_lights", "26": "light.living_room_rgbww_lights", + "27": "media_player.group", + "28": "media_player.browse", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} @@ -1017,6 +1019,12 @@ async def test_set_position_cover(hass_hue, hue_client) -> None: cover_test = hass_hue.states.get(cover_id) assert cover_test.state == "closed" + cover_json = await perform_get_light_state( + hue_client, "cover.living_room_window", HTTPStatus.OK + ) + assert cover_json["state"][HUE_API_STATE_ON] is False + assert cover_json["state"][HUE_API_STATE_BRI] == 1 + level = 20 brightness = round(level / 100 * 254) @@ -1093,6 +1101,7 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 33 await perform_put_light_state( @@ -1110,6 +1119,7 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert ( round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 66 ) # small rounding error in inverse operation @@ -1130,8 +1140,27 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 100 + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + False, + brightness=0, + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_PERCENTAGE] == 0 + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTPStatus.OK + ) + assert fan_json["state"][HUE_API_STATE_ON] is False + assert fan_json["state"][HUE_API_STATE_BRI] == 1 + async def test_put_with_form_urlencoded_content_type(hass_hue, hue_client) -> None: """Test the form with urlencoded content.""" @@ -1694,3 +1723,62 @@ async def test_specificly_exposed_entities( result_json = await async_get_lights(client) assert "1" in result_json + + +async def test_get_light_state_when_none(hass_hue: HomeAssistant, hue_client) -> None: + """Test the getting of light state when brightness is None.""" + hass_hue.states.async_set( + "light.ceiling_lights", + STATE_ON, + { + light.ATTR_BRIGHTNESS: None, + light.ATTR_RGB_COLOR: None, + light.ATTR_HS_COLOR: None, + light.ATTR_COLOR_TEMP: None, + light.ATTR_XY_COLOR: None, + light.ATTR_SUPPORTED_COLOR_MODES: [ + light.COLOR_MODE_COLOR_TEMP, + light.COLOR_MODE_HS, + light.COLOR_MODE_XY, + ], + light.ATTR_COLOR_MODE: light.COLOR_MODE_XY, + }, + ) + + light_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTPStatus.OK + ) + state = light_json["state"] + assert state[HUE_API_STATE_ON] is True + assert state[HUE_API_STATE_BRI] == 1 + assert state[HUE_API_STATE_HUE] == 0 + assert state[HUE_API_STATE_SAT] == 0 + assert state[HUE_API_STATE_CT] == 153 + + hass_hue.states.async_set( + "light.ceiling_lights", + STATE_OFF, + { + light.ATTR_BRIGHTNESS: None, + light.ATTR_RGB_COLOR: None, + light.ATTR_HS_COLOR: None, + light.ATTR_COLOR_TEMP: None, + light.ATTR_XY_COLOR: None, + light.ATTR_SUPPORTED_COLOR_MODES: [ + light.COLOR_MODE_COLOR_TEMP, + light.COLOR_MODE_HS, + light.COLOR_MODE_XY, + ], + light.ATTR_COLOR_MODE: light.COLOR_MODE_XY, + }, + ) + + light_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTPStatus.OK + ) + state = light_json["state"] + assert state[HUE_API_STATE_ON] is False + assert state[HUE_API_STATE_BRI] == 1 + assert state[HUE_API_STATE_HUE] == 0 + assert state[HUE_API_STATE_SAT] == 0 + assert state[HUE_API_STATE_CT] == 153 diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index f4a1f661f9bee2..522bbe5af06cdf 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -877,6 +877,114 @@ async def test_cost_sensor_handle_price_units( assert state.state == "20.0" +async def test_cost_sensor_handle_late_price_sensor( + setup_integration, + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test energy cost where the price sensor is not immediately available.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + } + price_attributes = { + ATTR_UNIT_OF_MEASUREMENT: f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": "sensor.energy_price", + "number_energy_price": None, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + # Initial state: 10kWh, price sensor not yet available + hass.states.async_set("sensor.energy_price", "unknown", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 10, + energy_attributes, + ) + + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Energy use bumped by 10 kWh, price sensor still not yet available + hass.states.async_set( + "sensor.energy_consumption", + 20, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Energy use bumped by 10 kWh, price sensor now available + hass.states.async_set("sensor.energy_price", "1", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 30, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "20.0" + + # Energy use bumped by 10 kWh, price sensor available + hass.states.async_set( + "sensor.energy_consumption", + 40, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "30.0" + + # Energy use bumped by 10 kWh, price sensor no longer available + hass.states.async_set("sensor.energy_price", "unknown", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 50, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "30.0" + + # Energy use bumped by 10 kWh, price sensor again available + hass.states.async_set("sensor.energy_price", "2", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 60, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "70.0" + + @pytest.mark.parametrize( "unit", (UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr index 68c46a705d79f2..9b4b3bfc635d38 100644 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ b/tests/components/energyzero/snapshots/test_config_flow.ambr @@ -11,6 +11,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'energyzero', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -19,6 +20,7 @@ 'disabled_by': None, 'domain': 'energyzero', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/energyzero/snapshots/test_services.ambr b/tests/components/energyzero/snapshots/test_services.ambr new file mode 100644 index 00000000000000..73d161477d0ba2 --- /dev/null +++ b/tests/components/energyzero/snapshots/test_services.ambr @@ -0,0 +1,2401 @@ +# serializer version: 1 +# name: test_service[end0-start0-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py new file mode 100644 index 00000000000000..c0b54729e03360 --- /dev/null +++ b/tests/components/energyzero/test_services.py @@ -0,0 +1,160 @@ +"""Tests for the services provided by the EnergyZero integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components.energyzero.const import DOMAIN +from homeassistant.components.energyzero.services import ( + ATTR_CONFIG_ENTRY, + ENERGY_SERVICE_NAME, + GAS_SERVICE_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("init_integration") +async def test_has_services( + hass: HomeAssistant, +) -> None: + """Test the existence of the EnergyZero Service.""" + assert hass.services.has_service(DOMAIN, GAS_SERVICE_NAME) + assert hass.services.has_service(DOMAIN, ENERGY_SERVICE_NAME) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +@pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}]) +@pytest.mark.parametrize("start", [{"start": "2023-01-01 00:00:00"}, {}]) +@pytest.mark.parametrize("end", [{"end": "2023-01-01 00:00:00"}, {}]) +async def test_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + service: str, + incl_vat: dict[str, bool], + start: dict[str, str], + end: dict[str, str], +) -> None: + """Test the EnergyZero Service.""" + entry = {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} + + data = entry | incl_vat | start | end + + assert snapshot == await hass.services.async_call( + DOMAIN, + service, + data, + blocking=True, + return_response=True, + ) + + +@pytest.fixture +def config_entry_data( + mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest +) -> dict[str, str]: + """Fixture for the config entry.""" + if "config_entry" in request.param and request.param["config_entry"] is True: + return {"config_entry": mock_config_entry.entry_id} + + return request.param + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +@pytest.mark.parametrize( + ("config_entry_data", "service_data", "error", "error_message"), + [ + ({}, {}, vol.er.Error, "required key not provided .+"), + ( + {"config_entry": True}, + {}, + vol.er.Error, + "required key not provided .+", + ), + ( + {}, + {"incl_vat": True}, + vol.er.Error, + "required key not provided .+", + ), + ( + {"config_entry": True}, + {"incl_vat": "incorrect vat"}, + vol.er.Error, + "expected bool for dictionary value .+", + ), + ( + {"config_entry": "incorrect entry"}, + {"incl_vat": True}, + ServiceValidationError, + "Invalid config entry.+", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "start": "incorrect date", + }, + ServiceValidationError, + "Invalid datetime provided.", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "end": "incorrect date", + }, + ServiceValidationError, + "Invalid datetime provided.", + ), + ], + indirect=["config_entry_data"], +) +async def test_service_validation( + hass: HomeAssistant, + service: str, + config_entry_data: dict[str, str], + service_data: dict[str, str], + error: type[Exception], + error_message: str, +) -> None: + """Test the EnergyZero Service validation.""" + + with pytest.raises(error, match=error_message): + await hass.services.async_call( + DOMAIN, + service, + config_entry_data | service_data, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +async def test_service_called_with_unloaded_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + service: str, +) -> None: + """Test service calls with unloaded config entry.""" + + await mock_config_entry.async_unload(hass) + + data = {"config_entry": mock_config_entry.entry_id, "incl_vat": True} + + with pytest.raises( + ServiceValidationError, match=f"{mock_config_entry.title} is not loaded" + ): + await hass.services.async_call( + DOMAIN, + service, + data, + blocking=True, + return_response=True, + ) diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 41cbb2391291f9..185f65aa8924a4 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -49,6 +49,7 @@ def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): """Define a mocked Envoy fixture.""" mock_envoy = Mock(spec=Envoy) mock_envoy.serial_number = serial_number + mock_envoy.firmware = "7.1.2" mock_envoy.authenticate = mock_authenticate mock_envoy.setup = mock_setup mock_envoy.auth = mock_auth @@ -89,7 +90,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/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 098fc4ee37e95c..9266ffcf94ec9a 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -15,6 +15,7 @@ 'disabled_by': None, 'domain': 'enphase_envoy', 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -24,5 +25,6 @@ 'unique_id': '**REDACTED**', 'version': 1, }), + 'envoy_firmware': '7.1.2', }) # --- diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index e3058697f3ec99..b745ac02693814 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -6,12 +6,8 @@ import pytest from homeassistant import config_entries, data_entry_flow -from homeassistant.components.environment_canada.const import ( - CONF_LANGUAGE, - CONF_STATION, - DOMAIN, -) -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/environment_canada/test_diagnostics.py b/tests/components/environment_canada/test_diagnostics.py index 3eedb7a0ddb9b2..fb1597e36223df 100644 --- a/tests/components/environment_canada/test_diagnostics.py +++ b/tests/components/environment_canada/test_diagnostics.py @@ -5,12 +5,8 @@ from syrupy import SnapshotAssertion -from homeassistant.components.environment_canada.const import ( - CONF_LANGUAGE, - CONF_STATION, - DOMAIN, -) -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/epson/test_media_player.py b/tests/components/epson/test_media_player.py new file mode 100644 index 00000000000000..874a12173d6784 --- /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/bluetooth/__init__.py b/tests/components/esphome/bluetooth/__init__.py new file mode 100644 index 00000000000000..10ff361d85c6f5 --- /dev/null +++ b/tests/components/esphome/bluetooth/__init__.py @@ -0,0 +1 @@ +"""Bluetooth tests for ESPHome.""" diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py new file mode 100644 index 00000000000000..cd250bc10805fb --- /dev/null +++ b/tests/components/esphome/bluetooth/test_client.py @@ -0,0 +1,57 @@ +"""Tests for ESPHomeClient.""" +from __future__ import annotations + +from aioesphomeapi import APIClient, APIVersion, BluetoothProxyFeature, DeviceInfo +from bleak.exc import BleakError +from bleak_esphome.backend.cache import ESPHomeBluetoothCache +from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData +from bleak_esphome.backend.device import ESPHomeBluetoothDevice +from bleak_esphome.backend.scanner import ESPHomeScanner +import pytest + +from homeassistant.components.bluetooth import HaBluetoothConnector +from homeassistant.core import HomeAssistant + +from tests.components.bluetooth import generate_ble_device + +ESP_MAC_ADDRESS = "AA:BB:CC:DD:EE:FF" +ESP_NAME = "proxy" + + +@pytest.fixture(name="client_data") +async def client_data_fixture( + hass: HomeAssistant, mock_client: APIClient +) -> ESPHomeClientData: + """Return a client data fixture.""" + connector = HaBluetoothConnector(ESPHomeClientData, ESP_MAC_ADDRESS, lambda: True) + return ESPHomeClientData( + bluetooth_device=ESPHomeBluetoothDevice(ESP_NAME, ESP_MAC_ADDRESS), + cache=ESPHomeBluetoothCache(), + client=mock_client, + device_info=DeviceInfo( + mac_address=ESP_MAC_ADDRESS, + name=ESP_NAME, + bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN + | BluetoothProxyFeature.ACTIVE_CONNECTIONS + | BluetoothProxyFeature.REMOTE_CACHING + | BluetoothProxyFeature.PAIRING + | BluetoothProxyFeature.CACHE_CLEARING + | BluetoothProxyFeature.RAW_ADVERTISEMENTS, + ), + api_version=APIVersion(1, 9), + title=ESP_NAME, + scanner=ESPHomeScanner(ESP_MAC_ADDRESS, ESP_NAME, connector, True), + ) + + +async def test_client_usage_while_not_connected(client_data: ESPHomeClientData) -> None: + """Test client usage while not connected.""" + ble_device = generate_ble_device( + "CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1} + ) + + client = ESPHomeClient(ble_device, client_data=client_data) + with pytest.raises( + BleakError, match=f"{ESP_NAME}.*{ESP_MAC_ADDRESS}.*not connected" + ): + await client.write_gatt_char("test", b"test") is False diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 48b0868e406cf1..3acc5112720fd6 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -10,9 +10,11 @@ from aioesphomeapi import ( APIClient, APIVersion, + BluetoothProxyFeature, DeviceInfo, EntityInfo, EntityState, + HomeassistantServiceCall, ReconnectLogic, UserService, ) @@ -71,6 +73,7 @@ def mock_config_entry(hass) -> MockConfigEntry: CONF_NOISE_PSK: "12345678123456781234567812345678", CONF_DEVICE_NAME: "test", }, + # ESPHome unique ids are lower case unique_id="11:22:33:44:55:aa", ) config_entry.add_to_hass(hass) @@ -95,7 +98,8 @@ def mock_device_info() -> DeviceInfo: uses_password=False, name="test", legacy_bluetooth_proxy_version=0, - mac_address="11:22:33:44:55:aa", + # ESPHome mac addresses are UPPER case + mac_address="11:22:33:44:55:AA", esphome_version="1.0.0", ) @@ -173,6 +177,7 @@ def __init__(self, entry: MockConfigEntry) -> None: """Init the mock.""" self.entry = entry self.state_callback: Callable[[EntityState], None] + self.service_call_callback: Callable[[HomeassistantServiceCall], None] self.on_disconnect: Callable[[bool], None] self.on_connect: Callable[[bool], None] @@ -180,6 +185,16 @@ def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> N """Set the state callback.""" self.state_callback = state_callback + def set_service_call_callback( + self, callback: Callable[[HomeassistantServiceCall], None] + ) -> None: + """Set the service call callback.""" + self.service_call_callback = callback + + def mock_service_call(self, service_call: HomeassistantServiceCall) -> None: + """Mock a service call.""" + self.service_call_callback(service_call) + def set_state(self, state: EntityState) -> None: """Mock setting state.""" self.state_callback(state) @@ -229,7 +244,7 @@ async def _mock_generic_device_entry( "name": "test", "friendly_name": "Test", "esphome_version": "1.0.0", - "mac_address": "11:22:33:44:55:aa", + "mac_address": "11:22:33:44:55:AA", } device_info = DeviceInfo(**(default_device_info | mock_device_info)) @@ -239,12 +254,19 @@ async def _subscribe_states(callback: Callable[[EntityState], None]) -> None: for state in states: callback(state) + async def _subscribe_service_calls( + callback: Callable[[HomeassistantServiceCall], None], + ) -> None: + """Subscribe to service calls.""" + mock_device.set_service_call_callback(callback) + mock_client.device_info = AsyncMock(return_value=device_info) mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) mock_client.list_entities_services = AsyncMock( return_value=mock_list_entities_services ) mock_client.subscribe_states = _subscribe_states + mock_client.subscribe_service_calls = _subscribe_service_calls try_connect_done = Event() @@ -311,6 +333,54 @@ async def mock_voice_assistant_v2_entry(mock_voice_assistant_entry) -> MockConfi return await mock_voice_assistant_entry(version=2) +@pytest.fixture +async def mock_bluetooth_entry( + hass: HomeAssistant, + mock_client: APIClient, +): + """Set up an ESPHome entry with bluetooth.""" + + async def _mock_bluetooth_entry( + bluetooth_proxy_feature_flags: BluetoothProxyFeature, + ) -> MockESPHomeDevice: + return await _mock_generic_device_entry( + hass, + mock_client, + {"bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags}, + ([], []), + [], + ) + + return _mock_bluetooth_entry + + +@pytest.fixture +async def mock_bluetooth_entry_with_raw_adv(mock_bluetooth_entry) -> MockESPHomeDevice: + """Set up an ESPHome entry with bluetooth and raw advertisements.""" + return await mock_bluetooth_entry( + bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN + | BluetoothProxyFeature.ACTIVE_CONNECTIONS + | BluetoothProxyFeature.REMOTE_CACHING + | BluetoothProxyFeature.PAIRING + | BluetoothProxyFeature.CACHE_CLEARING + | BluetoothProxyFeature.RAW_ADVERTISEMENTS + ) + + +@pytest.fixture +async def mock_bluetooth_entry_with_legacy_adv( + mock_bluetooth_entry, +) -> MockESPHomeDevice: + """Set up an ESPHome entry with bluetooth with legacy advertisements.""" + return await mock_bluetooth_entry( + bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN + | BluetoothProxyFeature.ACTIVE_CONNECTIONS + | BluetoothProxyFeature.REMOTE_CACHING + | BluetoothProxyFeature.PAIRING + | BluetoothProxyFeature.CACHE_CLEARING + ) + + @pytest.fixture async def mock_generic_device_entry( hass: HomeAssistant, diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index d8de8f06bc638e..0d2f0e60b82d78 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -12,6 +12,7 @@ 'disabled_by': None, 'domain': 'esphome', 'entry_id': '08d821dc059cf4f645cb024d32c8e708', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py new file mode 100644 index 00000000000000..46858c5826b61a --- /dev/null +++ b/tests/components/esphome/test_bluetooth.py @@ -0,0 +1,46 @@ +"""Test the ESPHome bluetooth integration.""" + +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant + +from .conftest import MockESPHomeDevice + + +async def test_bluetooth_connect_with_raw_adv( + hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice +) -> None: + """Test bluetooth connect with raw advertisements.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is not None + assert scanner.connectable is True + assert scanner.scanning is True + assert scanner.connector.can_connect() is False # no connection slots + await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) + await hass.async_block_till_done() + + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is None + await mock_bluetooth_entry_with_raw_adv.mock_connect() + await hass.async_block_till_done() + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner.scanning is True + + +async def test_bluetooth_connect_with_legacy_adv( + hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice +) -> None: + """Test bluetooth connect with legacy advertisements.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is not None + assert scanner.connectable is True + assert scanner.scanning is True + assert scanner.connector.can_connect() is False # no connection slots + await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True) + await hass.async_block_till_done() + + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is None + await mock_bluetooth_entry_with_legacy_adv.mock_connect() + await hass.async_block_till_done() + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner.scanning is True diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 7e00fd22a1c7cb..cb9a084d09461f 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -1,6 +1,7 @@ """Test ESPHome climates.""" +import math from unittest.mock import call from aioesphomeapi import ( @@ -15,8 +16,13 @@ ) from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, 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 +31,7 @@ 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 +319,116 @@ 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() + + +async def test_climate_entity_with_inf_value( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic climate entity with infinite temp.""" + 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=math.inf, + target_temperature=math.inf, + 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 + assert ATTR_TEMPERATURE not in attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] is None diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 01ba07852d6609..4161e69efd07da 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -432,7 +432,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( DeviceInfo( uses_password=False, name="test", - mac_address="11:22:33:44:55:aa", + mac_address="11:22:33:44:55:AA", ), ] diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index d8732ea0453f0b..320b20832c8312 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -45,6 +45,25 @@ async def test_restore_dashboard_storage( assert mock_get_or_create.call_count == 1 +async def test_restore_dashboard_storage_end_to_end( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage +) -> MockConfigEntry: + """Restore dashboard url and slug from storage.""" + hass_storage[dashboard.STORAGE_KEY] = { + "version": dashboard.STORAGE_VERSION, + "minor_version": dashboard.STORAGE_VERSION, + "key": dashboard.STORAGE_KEY, + "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, + } + with patch( + "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI" + ) as mock_dashboard_api: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state == ConfigEntryState.LOADED + assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052" + + async def test_setup_dashboard_fails( hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage ) -> MockConfigEntry: @@ -168,6 +187,9 @@ async def test_dashboard_supports_update(hass: HomeAssistant, mock_dashboard) -> # No data assert not dash.supports_update + await dash.async_refresh() + assert dash.supports_update is None + # supported version mock_dashboard["configured"].append( { @@ -177,11 +199,11 @@ async def test_dashboard_supports_update(hass: HomeAssistant, mock_dashboard) -> } ) await dash.async_refresh() - - assert dash.supports_update + assert dash.supports_update is True # unsupported version + dash.supports_update = None mock_dashboard["configured"][0]["current_version"] = "2023.1.0" await dash.async_refresh() - assert not dash.supports_update + assert dash.supports_update is False diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 6000b270d879b4..d528010af1b259 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -1,8 +1,13 @@ """Tests for the diagnostics data provided by the ESPHome integration.""" +from unittest.mock import ANY + from syrupy import SnapshotAssertion +from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant +from .conftest import MockESPHomeDevice + from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -20,3 +25,77 @@ async def test_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) assert result == snapshot + + +async def test_diagnostics_with_bluetooth( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, +) -> None: + """Test diagnostics for config entry with Bluetooth.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is not None + assert scanner.connectable is True + entry = mock_bluetooth_entry_with_raw_adv.entry + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert result == { + "bluetooth": { + "available": True, + "connections_free": 0, + "connections_limit": 0, + "scanner": { + "connectable": True, + "discovered_device_timestamps": {}, + "discovered_devices_and_advertisement_data": [], + "last_detection": ANY, + "monotonic_time": ANY, + "name": "test (11:22:33:44:55:AA)", + "scanning": True, + "source": "11:22:33:44:55:AA", + "start_time": ANY, + "time_since_last_device_detection": {}, + "type": "ESPHomeScanner", + }, + }, + "config": { + "data": { + "device_name": "test", + "host": "test.local", + "password": "", + "port": 6053, + }, + "disabled_by": None, + "domain": "esphome", + "entry_id": ANY, + "minor_version": 1, + "options": {"allow_service_calls": False}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "title": "Mock Title", + "unique_id": "11:22:33:44:55:aa", + "version": 1, + }, + "storage_data": { + "api_version": {"major": 99, "minor": 99}, + "device_info": { + "bluetooth_proxy_feature_flags": 63, + "compilation_time": "", + "esphome_version": "1.0.0", + "friendly_name": "Test", + "has_deep_sleep": False, + "legacy_bluetooth_proxy_version": 0, + "mac_address": "**REDACTED**", + "manufacturer": "", + "model": "", + "name": "test", + "project_name": "", + "project_version": "", + "suggested_area": "", + "uses_password": False, + "voice_assistant_version": 0, + "webserver_port": 0, + }, + "services": [], + }, + } diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index fdc57b2dc24b3e..9a5cb441f28f3a 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -13,7 +13,13 @@ 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 0ba43092d019bb..a8535c38224605 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -51,7 +51,7 @@ async def test_migrate_entity_unique_id( 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" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" async def test_migrate_entity_unique_id_downgrade_upgrade( @@ -71,7 +71,7 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( entity_registry.async_get_or_create( "sensor", "esphome", - "11:22:33:44:55:aa-sensor-mysensor", + "11:22:33:44:55:AA-sensor-mysensor", suggested_object_id="new_sensor", disabled_by=None, ) @@ -108,4 +108,4 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( ) # 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" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 99f4bbc86a9fe2..6f383dcb6ba317 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -16,12 +16,14 @@ ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, + ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN, SERVICE_DECREASE_SPEED, SERVICE_INCREASE_SPEED, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, @@ -145,6 +147,7 @@ async def test_fan_entity_with_all_features_new_api( supports_direction=True, supports_speed=True, supports_oscillation=True, + supported_preset_modes=["Preset1", "Preset2"], ) ] states = [ @@ -154,6 +157,7 @@ async def test_fan_entity_with_all_features_new_api( oscillating=True, speed_level=3, direction=FanDirection.REVERSE, + preset_mode=None, ) ] user_service = [] @@ -270,6 +274,15 @@ async def test_fan_entity_with_all_features_new_api( ) mock_client.fan_command.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PRESET_MODE: "Preset1"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, preset_mode="Preset1")]) + mock_client.fan_command.reset_mock() + async def test_fan_entity_with_no_features_new_api( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry @@ -285,6 +298,7 @@ async def test_fan_entity_with_no_features_new_api( supports_direction=False, supports_speed=False, supports_oscillation=False, + supported_preset_modes=[], ) ] states = [FanState(key=1, state=True)] diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 99058ad3ed4d6c..3d0c1cc63eb8b9 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -29,6 +29,7 @@ ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_WHITE, DOMAIN as LIGHT_DOMAIN, FLASH_LONG, FLASH_SHORT, @@ -317,6 +318,68 @@ async def test_light_legacy_white_converted_to_brightness( mock_client.light_command.reset_mock() +async def test_light_legacy_white_with_rgb( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity with rgb and white.""" + mock_client.api_version = APIVersion(1, 7) + color_mode = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.WHITE + ) + color_mode_2 = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + ) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[color_mode, color_mode_2], + ) + ] + states = [LightState(key=1, state=True, brightness=100)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.RGB, + ColorMode.WHITE, + ] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_WHITE: 60}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + brightness=pytest.approx(0.23529411764705882), + white=1.0, + color_mode=color_mode, + ) + ] + ) + mock_client.light_command.reset_mock() + + async def test_light_brightness_on_off_with_unknown_color_mode( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry ) -> None: @@ -1676,3 +1739,139 @@ async def test_light_effects( ] ) mock_client.light_command.reset_mock() + + +async def test_only_cold_warm_white_support( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity with only cold warm white support.""" + mock_client.api_version = APIVersion(1, 7) + color_modes = ( + LightColorCapability.COLD_WARM_WHITE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + ) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[color_modes], + ) + ] + states = [ + LightState( + key=1, + state=True, + color_brightness=1, + brightness=100, + red=1, + green=1, + blue=1, + warm_white=1, + cold_white=1, + color_mode=color_modes, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 0 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [call(key=1, state=True, color_mode=color_modes)] + ) + mock_client.light_command.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + color_mode=color_modes, + brightness=pytest.approx(0.4980392156862745), + ) + ] + ) + mock_client.light_command.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + color_mode=color_modes, + color_temperature=400.0, + ) + ] + ) + mock_client.light_command.reset_mock() + + +async def test_light_no_color_modes( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity with no color modes.""" + mock_client.api_version = APIVersion(1, 7) + color_mode = 0 + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[color_mode], + ) + ] + states = [LightState(key=1, state=True, brightness=100)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.UNKNOWN] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls([call(key=1, state=True, color_mode=0)]) + mock_client.light_command.reset_mock() diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index d297dddee4af6d..1376e8bd41d674 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1,25 +1,219 @@ """Test ESPHome manager.""" from collections.abc import Awaitable, Callable -from unittest.mock import AsyncMock - -from aioesphomeapi import APIClient, DeviceInfo, EntityInfo, EntityState, UserService +from unittest.mock import AsyncMock, call + +from aioesphomeapi import ( + APIClient, + DeviceInfo, + EntityInfo, + EntityState, + HomeassistantServiceCall, + UserService, + UserServiceArg, + UserServiceArgType, +) import pytest from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.esphome.const import ( + CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, DOMAIN, STABLE_BLE_VERSION_STR, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall 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 -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_capture_events, async_mock_service + + +async def test_esphome_device_service_calls_not_allowed( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a device with service calls not allowed.""" + entity_info = [] + states = [] + user_service = [] + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"esphome_version": "2023.3.0"}, + ) + await hass.async_block_till_done() + mock_esphome_test = async_mock_service(hass, "esphome", "test") + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data={}, + ) + ) + await hass.async_block_till_done() + assert len(mock_esphome_test) == 0 + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + "esphome", "service_calls_not_enabled-11:22:33:44:55:aa" + ) + assert issue is not None + assert ( + "If you trust this device and want to allow access " + "for it to make Home Assistant service calls, you can " + "enable this functionality in the options flow" + ) in caplog.text + + +async def test_esphome_device_service_calls_allowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a device with service calls are allowed.""" + entity_info = [] + states = [] + user_service = [] + mock_config_entry.options = {CONF_ALLOW_SERVICE_CALLS: True} + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"esphome_version": "2023.3.0"}, + entry=mock_config_entry, + ) + await hass.async_block_till_done() + mock_calls: list[ServiceCall] = [] + + async def _mock_service(call: ServiceCall) -> None: + mock_calls.append(call) + + hass.services.async_register(DOMAIN, "test", _mock_service) + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data={"raw": "data"}, + ) + ) + await hass.async_block_till_done() + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + "esphome", "service_calls_not_enabled-11:22:33:44:55:aa" + ) + assert issue is None + assert len(mock_calls) == 1 + service_call = mock_calls[0] + assert service_call.domain == DOMAIN + assert service_call.service == "test" + assert service_call.data == {"raw": "data"} + mock_calls.clear() + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data_template={"raw": "{{invalid}}"}, + ) + ) + await hass.async_block_till_done() + assert ( + "Template variable warning: 'invalid' is undefined when rendering '{{invalid}}'" + in caplog.text + ) + assert len(mock_calls) == 1 + service_call = mock_calls[0] + assert service_call.domain == DOMAIN + assert service_call.service == "test" + assert service_call.data == {"raw": ""} + mock_calls.clear() + caplog.clear() + + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data_template={"raw": "{{-- invalid --}}"}, + ) + ) + await hass.async_block_till_done() + assert "TemplateSyntaxError" in caplog.text + assert "{{-- invalid --}}" in caplog.text + assert len(mock_calls) == 0 + mock_calls.clear() + caplog.clear() + + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data_template={"raw": "{{var}}"}, + variables={"var": "value"}, + ) + ) + await hass.async_block_till_done() + assert len(mock_calls) == 1 + service_call = mock_calls[0] + assert service_call.domain == DOMAIN + assert service_call.service == "test" + assert service_call.data == {"raw": "value"} + mock_calls.clear() + + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data_template={"raw": "valid"}, + ) + ) + await hass.async_block_till_done() + assert len(mock_calls) == 1 + service_call = mock_calls[0] + assert service_call.domain == DOMAIN + assert service_call.service == "test" + assert service_call.data == {"raw": "valid"} + mock_calls.clear() + + # Try firing events + events = async_capture_events(hass, "esphome.test") + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + is_event=True, + data={"raw": "event"}, + ) + ) + await hass.async_block_till_done() + assert len(events) == 1 + event = events[0] + assert event.data["raw"] == "event" + assert event.event_type == "esphome.test" + events.clear() + caplog.clear() + + # Try firing events for disallowed domain + events = async_capture_events(hass, "wrong.test") + device.mock_service_call( + HomeassistantServiceCall( + service="wrong.test", + is_event=True, + data={"raw": "event"}, + ) + ) + await hass.async_block_till_done() + assert len(events) == 0 + assert "Can only generate events under esphome domain" in caplog.text + events.clear() async def test_esphome_device_with_old_bluetooth( @@ -44,7 +238,7 @@ async def test_esphome_device_with_old_bluetooth( await hass.async_block_till_done() issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue( - "esphome", "ble_firmware_outdated-11:22:33:44:55:aa" + "esphome", "ble_firmware_outdated-11:22:33:44:55:AA" ) assert ( issue.learn_more_url @@ -86,7 +280,10 @@ async def test_esphome_device_with_password( issue_registry = ir.async_get(hass) assert ( issue_registry.async_get_issue( - "esphome", "api_password_deprecated-11:22:33:44:55:aa" + # This issue uses the ESPHome mac address which + # is always UPPER case + "esphome", + "api_password_deprecated-11:22:33:44:55:AA", ) is not None ) @@ -117,8 +314,10 @@ async def test_esphome_device_with_current_bluetooth( await hass.async_block_till_done() issue_registry = ir.async_get(hass) assert ( + # This issue uses the ESPHome device info mac address which + # is always UPPER case issue_registry.async_get_issue( - "esphome", "ble_firmware_outdated-11:22:33:44:55:aa" + "esphome", "ble_firmware_outdated-11:22:33:44:55:AA" ) is None ) @@ -332,3 +531,257 @@ 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)]) + + +async def test_esphome_device_with_dash_in_name_user_services( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with user services and a dash in the name.""" + entity_info = [] + states = [] + service1 = UserService( + name="my_service", + key=1, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), + UserServiceArg(name="arg2", type=UserServiceArgType.INT), + UserServiceArg(name="arg3", type=UserServiceArgType.FLOAT), + UserServiceArg(name="arg4", type=UserServiceArgType.STRING), + UserServiceArg(name="arg5", type=UserServiceArgType.BOOL_ARRAY), + UserServiceArg(name="arg6", type=UserServiceArgType.INT_ARRAY), + UserServiceArg(name="arg7", type=UserServiceArgType.FLOAT_ARRAY), + UserServiceArg(name="arg8", type=UserServiceArgType.STRING_ARRAY), + ], + ) + service2 = UserService( + name="simple_service", + key=2, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), + ], + ) + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[service1, service2], + device_info={"name": "with-dash"}, + states=states, + ) + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_my_service") + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + + await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": True}) + await hass.async_block_till_done() + + mock_client.execute_service.assert_has_calls( + [ + call( + UserService( + name="simple_service", + key=2, + args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], + ), + {"arg1": True}, + ) + ] + ) + mock_client.execute_service.reset_mock() + + # Verify the service can be removed + mock_client.list_entities_services = AsyncMock( + return_value=(entity_info, [service1]) + ) + await device.mock_disconnect(True) + await hass.async_block_till_done() + await device.mock_connect() + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_my_service") + assert not hass.services.has_service(DOMAIN, "with_dash_simple_service") + + +async def test_esphome_user_services_ignores_invalid_arg_types( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with user services and a dash in the name.""" + entity_info = [] + states = [] + service1 = UserService( + name="bad_service", + key=1, + args=[ + UserServiceArg(name="arg1", type="wrong"), + ], + ) + service2 = UserService( + name="simple_service", + key=2, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), + ], + ) + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[service1, service2], + device_info={"name": "with-dash"}, + states=states, + ) + await hass.async_block_till_done() + assert not hass.services.has_service(DOMAIN, "with_dash_bad_service") + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + + await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": True}) + await hass.async_block_till_done() + + mock_client.execute_service.assert_has_calls( + [ + call( + UserService( + name="simple_service", + key=2, + args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], + ), + {"arg1": True}, + ) + ] + ) + mock_client.execute_service.reset_mock() + + # Verify the service can be removed + mock_client.list_entities_services = AsyncMock( + return_value=(entity_info, [service2]) + ) + await device.mock_disconnect(True) + await hass.async_block_till_done() + await device.mock_connect() + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + assert not hass.services.has_service(DOMAIN, "with_dash_bad_service") + + +async def test_esphome_user_services_changes( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with user services that change arguments.""" + entity_info = [] + states = [] + service1 = UserService( + name="simple_service", + key=2, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), + ], + ) + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[service1], + device_info={"name": "with-dash"}, + states=states, + ) + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + + await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": True}) + await hass.async_block_till_done() + + mock_client.execute_service.assert_has_calls( + [ + call( + UserService( + name="simple_service", + key=2, + args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], + ), + {"arg1": True}, + ) + ] + ) + mock_client.execute_service.reset_mock() + + new_service1 = UserService( + name="simple_service", + key=2, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.FLOAT), + ], + ) + + # Verify the service can be updated + mock_client.list_entities_services = AsyncMock( + return_value=(entity_info, [new_service1]) + ) + await device.mock_disconnect(True) + await hass.async_block_till_done() + await device.mock_connect() + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + + await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": 4.5}) + await hass.async_block_till_done() + + mock_client.execute_service.assert_has_calls( + [ + call( + UserService( + name="simple_service", + key=2, + args=[UserServiceArg(name="arg1", type=UserServiceArgType.FLOAT)], + ), + {"arg1": 4.5}, + ) + ] + ) + mock_client.execute_service.reset_mock() diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 820ec9ad9c08bc..080976425f908b 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -118,7 +118,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( assert entry 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" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -156,7 +156,7 @@ async def test_generic_numeric_sensor_state_class_measurement( assert entry 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" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" assert entry.entity_category is None diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index d7b04f8448c446..d267a13145f996 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -9,7 +9,13 @@ from homeassistant.components.esphome.dashboard import async_get_dashboard from homeassistant.components.update import UpdateEntityFeature -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -100,7 +106,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 +127,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", @@ -368,3 +376,46 @@ async def test_update_entity_not_present_without_dashboard( state = hass.states.get("update.none_firmware") assert state is None + + +async def test_update_becomes_available_at_runtime( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + mock_dashboard, +) -> None: + """Test ESPHome update entity when the dashboard has no device at startup but gets them later.""" + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + features = state.attributes[ATTR_SUPPORTED_FEATURES] + # There are no devices on the dashboard so no + # way to tell the version so install is disabled + assert features is UpdateEntityFeature(0) + + # A device gets added to the dashboard + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + + await async_get_dashboard(hass).async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("update.test_firmware") + assert state is not None + # We now know the version so install is enabled + features = state.attributes[ATTR_SUPPORTED_FEATURES] + assert features is UpdateEntityFeature.INSTALL diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index ca74c99f0cd87b..38a33bfdec241c 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -337,6 +337,28 @@ 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, diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 66cda6a088a3bb..b8ba5fb6a18263 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -56,6 +56,9 @@ async def test_event() -> None: event_types=["short_press", "long_press"], device_class=EventDeviceClass.DOORBELL, ) + # Delete the cache since we changed the entity description + # at run time + del event.device_class assert event.event_types == ["short_press", "long_press"] assert event.device_class == EventDeviceClass.DOORBELL diff --git a/tests/components/evil_genius_labs/conftest.py b/tests/components/evil_genius_labs/conftest.py index 66dd8979d67707..a4f10fe97c4398 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 8338afc9c68f2f..828c13b6f167de 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -1,8 +1,22 @@ """Tests for fan platforms.""" import pytest -from homeassistant.components.fan import FanEntity +from homeassistant.components import fan +from homeassistant.components.fan import ( + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DOMAIN, + SERVICE_SET_PRESET_MODE, + FanEntity, + FanEntityFeature, + NotValidPresetModeError, +) from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import import_and_test_deprecated_constant_enum +from tests.testing_config.custom_components.test.fan import MockFan class BaseFan(FanEntity): @@ -82,3 +96,84 @@ 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" + + +@pytest.mark.parametrize(("enum"), list(fan.FanEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: fan.FanEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, fan, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockFan(FanEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockFan() + assert entity.supported_features_compat is FanEntityFeature(1) + assert "MockFan" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "FanEntityFeature.SET_SPEED" in caplog.text + caplog.clear() + assert entity.supported_features_compat is FanEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/fan/test_significant_change.py b/tests/components/fan/test_significant_change.py new file mode 100644 index 00000000000000..764abb6e8eea48 --- /dev/null +++ b/tests/components/fan/test_significant_change.py @@ -0,0 +1,51 @@ +"""Test the Fan significant change platform.""" +import pytest + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, +) +from homeassistant.components.fan.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Fan significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_PERCENTAGE_STEP: "1"}, {ATTR_PERCENTAGE_STEP: "2"}, False), + ({ATTR_PERCENTAGE: 1}, {ATTR_PERCENTAGE: 2}, True), + ({ATTR_PERCENTAGE: 1}, {ATTR_PERCENTAGE: 1.9}, False), + ({ATTR_PERCENTAGE: "invalid"}, {ATTR_PERCENTAGE: 1}, True), + ({ATTR_PERCENTAGE: 1}, {ATTR_PERCENTAGE: "invalid"}, False), + ({ATTR_DIRECTION: "front"}, {ATTR_DIRECTION: "front"}, False), + ({ATTR_DIRECTION: "front"}, {ATTR_DIRECTION: "back"}, True), + ({ATTR_OSCILLATING: True}, {ATTR_OSCILLATING: True}, False), + ({ATTR_OSCILLATING: True}, {ATTR_OSCILLATING: False}, True), + ({ATTR_PRESET_MODE: "auto"}, {ATTR_PRESET_MODE: "auto"}, False), + ({ATTR_PRESET_MODE: "auto"}, {ATTR_PRESET_MODE: "whoosh"}, True), + ( + {ATTR_PRESET_MODE: "auto", ATTR_OSCILLATING: True}, + {ATTR_PRESET_MODE: "auto", ATTR_OSCILLATING: False}, + True, + ), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Fan significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/fastdotcom/__init__.py b/tests/components/fastdotcom/__init__.py new file mode 100644 index 00000000000000..4c2ca6301afacd --- /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 00000000000000..17e75935dae8df --- /dev/null +++ b/tests/components/fastdotcom/test_config_flow.py @@ -0,0 +1,71 @@ +"""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.coordinator.fast_com"): + 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/fastdotcom/test_coordinator.py b/tests/components/fastdotcom/test_coordinator.py new file mode 100644 index 00000000000000..f51f0254714e6d --- /dev/null +++ b/tests/components/fastdotcom/test_coordinator.py @@ -0,0 +1,55 @@ +"""Test the FastdotcomDataUpdateCoordindator.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.fastdotcom.coordinator import DEFAULT_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_fastdotcom_data_update_coordinator( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test the update coordinator.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=10.0 + ): + freezer.tick(timedelta(hours=DEFAULT_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state.state == "10.0" + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", + side_effect=Exception("Test error"), + ): + freezer.tick(timedelta(hours=DEFAULT_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state.state is STATE_UNAVAILABLE diff --git a/tests/components/fastdotcom/test_init.py b/tests/components/fastdotcom/test_init.py new file mode 100644 index 00000000000000..0acaddf36fc186 --- /dev/null +++ b/tests/components/fastdotcom/test_init.py @@ -0,0 +1,115 @@ +"""Test for Sensibo component Init.""" +from __future__ import annotations + +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant import config_entries +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, STATE_UNKNOWN +from homeassistant.core import CoreState, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unload an entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == config_entries.ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_from_import(hass: HomeAssistant) -> None: + """Test imported entry.""" + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await async_setup_component( + hass, + DOMAIN, + {"fastdotcom": {}}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" + + +async def test_delayed_speedtest_during_startup( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test delayed speedtest during startup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ), patch.object(hass, "state", CoreState.starting): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == config_entries.ConfigEntryState.LOADED + state = hass.states.get("sensor.fast_com_download") + assert state is not None + # Assert state is unknown as coordinator is not allowed to start and fetch data yet + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "0" + + assert config_entry.state == config_entries.ConfigEntryState.LOADED + + +async def test_service_deprecated( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test deprecated service.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + "speedtest", + {}, + blocking=True, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "service_deprecation") + assert issue + assert issue.is_fixable is True + assert issue.translation_key == "service_deprecation" diff --git a/tests/components/fastdotcom/test_sensor.py b/tests/components/fastdotcom/test_sensor.py new file mode 100644 index 00000000000000..47826bf35cf2f2 --- /dev/null +++ b/tests/components/fastdotcom/test_sensor.py @@ -0,0 +1,31 @@ +"""Test the FastdotcomDataUpdateCoordindator.""" +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_fastdotcom_data_update_coordinator( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test the update coordinator.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 345c37dc8f1620..cd90694093144d 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -68,6 +68,12 @@ def fixture_feed_atom_event(hass: HomeAssistant) -> bytes: return load_fixture_bytes("feedreader5.xml") +@pytest.fixture(name="feed_identically_timed_events") +def fixture_feed_identically_timed_events(hass: HomeAssistant) -> bytes: + """Load test feed data for two events published at the exact same time.""" + return load_fixture_bytes("feedreader6.xml") + + @pytest.fixture(name="events") async def fixture_events(hass: HomeAssistant) -> list[Event]: """Fixture that catches alexa events.""" @@ -285,6 +291,63 @@ async def test_atom_feed(hass: HomeAssistant, events, feed_atom_event) -> None: assert events[0].data.updated_parsed.tm_min == 30 +async def test_feed_identical_timestamps( + hass: HomeAssistant, events, feed_identically_timed_events +) -> None: + """Test feed with 2 entries with identical timestamps.""" + with patch( + "feedparser.http.get", + return_value=feed_identically_timed_events, + ), patch( + "homeassistant.components.feedreader.StoredData.get_timestamp", + return_value=gmtime( + datetime.fromisoformat("1970-01-01T00:00:00.0+0000").timestamp() + ), + ): + assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert len(events) == 2 + assert events[0].data.title == "Title 1" + assert events[1].data.title == "Title 2" + assert events[0].data.link == "http://www.example.com/link/1" + assert events[1].data.link == "http://www.example.com/link/2" + assert events[0].data.id == "GUID 1" + assert events[1].data.id == "GUID 2" + assert ( + events[0].data.updated_parsed.tm_year + == events[1].data.updated_parsed.tm_year + == 2018 + ) + assert ( + events[0].data.updated_parsed.tm_mon + == events[1].data.updated_parsed.tm_mon + == 4 + ) + assert ( + events[0].data.updated_parsed.tm_mday + == events[1].data.updated_parsed.tm_mday + == 30 + ) + assert ( + events[0].data.updated_parsed.tm_hour + == events[1].data.updated_parsed.tm_hour + == 15 + ) + assert ( + events[0].data.updated_parsed.tm_min + == events[1].data.updated_parsed.tm_min + == 10 + ) + assert ( + events[0].data.updated_parsed.tm_sec + == events[1].data.updated_parsed.tm_sec + == 0 + ) + + async def test_feed_updates( hass: HomeAssistant, events, feed_one_event, feed_two_event ) -> None: diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index 0bcfcc380940c5..9cde648d27c637 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -2,6 +2,7 @@ import os from unittest.mock import call, mock_open, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import notify @@ -28,7 +29,9 @@ async def test_bad_config(hass: HomeAssistant) -> None: True, ], ) -async def test_notify_file(hass: HomeAssistant, timestamp: bool) -> None: +async def test_notify_file( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, timestamp: bool +) -> None: """Test the notify file output.""" filename = "mock_file" message = "one, two, testing, testing" @@ -47,10 +50,12 @@ async def test_notify_file(hass: HomeAssistant, timestamp: bool) -> None: ) assert handle_config[notify.DOMAIN] + freezer.move_to(dt_util.utcnow()) + m_open = mock_open() with patch("homeassistant.components.file.notify.open", m_open, create=True), patch( "homeassistant.components.file.notify.os.stat" - ) as mock_st, patch("homeassistant.util.dt.utcnow", return_value=dt_util.utcnow()): + ) as mock_st: mock_st.return_value.st_size = 0 title = ( f"{ATTR_TITLE_DEFAULT} notifications " diff --git a/tests/components/fints/__init__.py b/tests/components/fints/__init__.py new file mode 100644 index 00000000000000..6a2b1d96d206e5 --- /dev/null +++ b/tests/components/fints/__init__.py @@ -0,0 +1 @@ +"""Tests for FinTS component.""" diff --git a/tests/components/fints/test_client.py b/tests/components/fints/test_client.py new file mode 100644 index 00000000000000..429d391b07e33c --- /dev/null +++ b/tests/components/fints/test_client.py @@ -0,0 +1,95 @@ +"""Tests for the FinTS client.""" + +from typing import Optional + +from fints.client import BankIdentifier, FinTSOperations +import pytest + +from homeassistant.components.fints.sensor import ( + BankCredentials, + FinTsClient, + SEPAAccount, +) + +BANK_INFORMATION = { + "bank_identifier": BankIdentifier(country_identifier="280", bank_code="50010517"), + "currency": "EUR", + "customer_id": "0815", + "owner_name": ["SURNAME, FIRSTNAME"], + "subaccount_number": None, + "supported_operations": { + FinTSOperations.GET_BALANCE: True, + FinTSOperations.GET_CREDIT_CARD_TRANSACTIONS: False, + FinTSOperations.GET_HOLDINGS: False, + FinTSOperations.GET_SCHEDULED_DEBITS_MULTIPLE: False, + FinTSOperations.GET_SCHEDULED_DEBITS_SINGLE: False, + FinTSOperations.GET_SEPA_ACCOUNTS: True, + FinTSOperations.GET_STATEMENT: False, + FinTSOperations.GET_STATEMENT_PDF: False, + FinTSOperations.GET_TRANSACTIONS: True, + FinTSOperations.GET_TRANSACTIONS_XML: False, + }, +} + + +@pytest.mark.parametrize( + ( + "account_number", + "iban", + "product_name", + "account_type", + "expected_balance_result", + "expected_holdings_result", + ), + [ + ("GIRO1", "GIRO1", "Valid balance account", 5, True, False), + (None, None, "Invalid account", None, False, False), + ("GIRO2", "GIRO2", "Account without type", None, False, False), + ("GIRO3", "GIRO3", "Balance account from fallback", None, True, False), + ("DEPOT1", "DEPOT1", "Valid holdings account", 33, False, True), + ("DEPOT2", "DEPOT2", "Holdings account from fallback", None, False, True), + ], +) +async def test_account_type( + account_number: Optional[str], + iban: Optional[str], + product_name: str, + account_type: Optional[int], + expected_balance_result: bool, + expected_holdings_result: bool, +) -> None: + """Check client methods is_balance_account and is_holdings_account.""" + credentials = BankCredentials( + blz=1234, login="test", pin="0000", url="https://example.com" + ) + account_config = {"GIRO3": True} + holdings_config = {"DEPOT2": True} + + client = FinTsClient( + credentials=credentials, + name="test", + account_config=account_config, + holdings_config=holdings_config, + ) + + client._account_information_fetched = True + client._account_information = { + iban: BANK_INFORMATION + | { + "account_number": account_number, + "iban": iban, + "product_name": product_name, + "type": account_type, + } + } + + sepa_account = SEPAAccount( + iban=iban, + bic="BANCODELTEST", + accountnumber=account_number, + subaccount=None, + blz="12345", + ) + + assert client.is_balance_account(sepa_account) == expected_balance_result + assert client.is_holdings_account(sepa_account) == expected_holdings_result diff --git a/tests/components/firmata/test_config_flow.py b/tests/components/firmata/test_config_flow.py index d4bf159c5c948a..474455fc164f5d 100644 --- a/tests/components/firmata/test_config_flow.py +++ b/tests/components/firmata/test_config_flow.py @@ -31,7 +31,7 @@ async def test_import_cannot_connect_serial(hass: HomeAssistant) -> None: with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -48,7 +48,7 @@ async def test_import_cannot_connect_serial_timeout(hass: HomeAssistant) -> None with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialTimeoutException, + side_effect=serial.SerialTimeoutException, ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 682fb0edd3b092..a076be7f63d2fa 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -32,6 +32,15 @@ FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" FAKE_AUTH_IMPL = "conftest-imported-cred" +FULL_NAME = "First Last" +DISPLAY_NAME = "First L." +PROFILE_DATA = { + "fullName": FULL_NAME, + "displayName": DISPLAY_NAME, + "displayNameSetting": "name", + "firstName": "First", + "lastName": "Last", +} PROFILE_API_URL = "https://api.fitbit.com/1/user/-/profile.json" DEVICES_API_URL = "https://api.fitbit.com/1/user/-/devices.json" @@ -214,20 +223,34 @@ def mock_profile_locale() -> str: return "en_US" +@pytest.fixture(name="profile_data") +def mock_profile_data() -> dict[str, Any]: + """Fixture to return other profile data fields.""" + return PROFILE_DATA + + +@pytest.fixture(name="profile_response") +def mock_profile_response( + profile_id: str, profile_locale: str, profile_data: dict[str, Any] +) -> dict[str, Any]: + """Fixture to construct the fake profile API response.""" + return { + "user": { + "encodedId": profile_id, + "locale": profile_locale, + **profile_data, + }, + } + + @pytest.fixture(name="profile", autouse=True) -def mock_profile(requests_mock: Mocker, profile_id: str, profile_locale: str) -> None: +def mock_profile(requests_mock: Mocker, profile_response: dict[str, Any]) -> None: """Fixture to setup fake requests made to Fitbit API during config flow.""" requests_mock.register_uri( "GET", PROFILE_API_URL, status_code=HTTPStatus.OK, - json={ - "user": { - "encodedId": profile_id, - "fullName": "My name", - "locale": profile_locale, - }, - }, + json=profile_response, ) diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index d51379c9adc0f5..78d20b0fb58d6d 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -17,8 +17,10 @@ from .conftest import ( CLIENT_ID, + DISPLAY_NAME, FAKE_AUTH_IMPL, PROFILE_API_URL, + PROFILE_DATA, PROFILE_USER_ID, SERVER_ACCESS_TOKEN, ) @@ -76,7 +78,7 @@ async def test_full_flow( entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 config_entry = entries[0] - assert config_entry.title == "My name" + assert config_entry.title == DISPLAY_NAME assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) @@ -286,7 +288,7 @@ async def test_import_fitbit_config( # Verify valid profile can be fetched from the API config_entry = entries[0] - assert config_entry.title == "My name" + assert config_entry.title == DISPLAY_NAME assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) @@ -598,3 +600,60 @@ async def test_reauth_wrong_user_id( assert result.get("reason") == "wrong_account" assert len(mock_setup.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("profile_data", "expected_title"), + [ + (PROFILE_DATA, DISPLAY_NAME), + ({"displayName": DISPLAY_NAME}, DISPLAY_NAME), + ], + ids=("full_profile_data", "display_name_only"), +) +async def test_partial_profile_data( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, + expected_title: str, +) -> None: + """Check full flow.""" + 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": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + config_entry = entries[0] + assert config_entry.title == expected_title diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index b6bf75c1c69b24..74312348af1420 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -106,19 +106,29 @@ async def test_token_refresh_success( ) -@pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize( + ("token_expiration_time", "server_status"), + [ + (12345, HTTPStatus.UNAUTHORIZED), + (12345, HTTPStatus.BAD_REQUEST), + ], +) +@pytest.mark.parametrize("closing", [True, False]) async def test_token_requires_reauth( hass: HomeAssistant, integration_setup: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_credentials: None, + server_status: HTTPStatus, + closing: bool, ) -> None: """Test where token is expired and the refresh attempt requires reauth.""" aioclient_mock.post( OAUTH2_TOKEN, - status=HTTPStatus.UNAUTHORIZED, + status=server_status, + closing=closing, ) assert not await integration_setup() diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 08c9761bce24e9..91aafd944b085a 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -599,21 +599,25 @@ async def test_settings_scope_config_entry( @pytest.mark.parametrize( - ("scopes"), - [(["heartrate"])], + ("scopes", "server_status"), + [ + (["heartrate"], HTTPStatus.INTERNAL_SERVER_ERROR), + (["heartrate"], HTTPStatus.BAD_REQUEST), + ], ) async def test_sensor_update_failed( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], requests_mock: Mocker, + server_status: HTTPStatus, ) -> None: """Test a failed sensor update when talking to the API.""" requests_mock.register_uri( "GET", TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + status_code=server_status, ) assert await integration_setup() @@ -808,3 +812,60 @@ async def test_device_battery_level_reauth_required( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" + + +@pytest.mark.parametrize( + ("scopes", "response_data", "expected_state"), + [ + (["heartrate"], {}, "unknown"), + ( + ["heartrate"], + { + "restingHeartRate": 120, + }, + "120", + ), + ( + ["heartrate"], + { + "restingHeartRate": 0, + }, + "0", + ), + ], + ids=("missing", "valid", "zero"), +) +async def test_resting_heart_rate_responses( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + response_data: dict[str, Any], + expected_state: str, +) -> None: + """Test resting heart rate sensor with various values from response.""" + + register_timeseries( + "activities/heart", + timeseries_response( + "activities-heart", + { + "customHeartRateZones": [], + "heartRateZones": [ + { + "caloriesOut": 0, + "max": 220, + "min": 159, + "minutes": 0, + "name": "Peak", + }, + ], + **response_data, + }, + ), + ) + assert await integration_setup() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == expected_state diff --git a/tests/components/flexit_bacnet/__init__.py b/tests/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000000..4cae6e4f4bfe0e --- /dev/null +++ b/tests/components/flexit_bacnet/__init__.py @@ -0,0 +1 @@ +"""Tests for the Flexit Nordic (BACnet) integration.""" diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py new file mode 100644 index 00000000000000..b136b134e0188a --- /dev/null +++ b/tests/components/flexit_bacnet/conftest.py @@ -0,0 +1,44 @@ +"""Configuration for Flexit Nordic (BACnet) tests.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +async def flow_id(hass: HomeAssistant) -> str: + """Return initial ID for user-initiated configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + return result["flow_id"] + + +@pytest.fixture(autouse=True) +def mock_serial_number_and_device_name(): + """Mock serial number of the device.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.serial_number", + "0000-0001", + ), patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.device_name", + "Device Name", + ): + yield + + +@pytest.fixture +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.flexit_bacnet.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock diff --git a/tests/components/flexit_bacnet/test_config_flow.py b/tests/components/flexit_bacnet/test_config_flow.py new file mode 100644 index 00000000000000..ed513587af6fc1 --- /dev/null +++ b/tests/components/flexit_bacnet/test_config_flow.py @@ -0,0 +1,120 @@ +"""Test the Flexit Nordic (BACnet) config flow.""" +import asyncio.exceptions +from unittest.mock import patch + +from flexit_bacnet import DecodingError +import pytest + +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None: + """Test we get the form and the happy path works.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Device Name" + assert result["context"]["unique_id"] == "0000-0001" + assert result["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + asyncio.exceptions.TimeoutError, + "cannot_connect", + ), + (ConnectionError, "cannot_connect"), + (DecodingError, "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_fails( + hass: HomeAssistant, flow_id: str, error: Exception, message: str, mock_setup_entry +) -> None: + """Test that we return 'cannot_connect' error when attempting to connect to an incorrect IP address. + + The flexit_bacnet library raises asyncio.exceptions.TimeoutError in that scenario. + """ + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": message} + assert len(mock_setup_entry.mock_calls) == 0 + + # ensure that user can recover from this error + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Device Name" + assert result2["context"]["unique_id"] == "0000-0001" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_device_already_exist(hass: HomeAssistant, flow_id: str) -> None: + """Test that we cannot add already added device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + unique_id="0000-0001", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index 974a029d14306f..6ddb9e1687fcb1 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -23,7 +23,6 @@ CONF_CUSTOM_EFFECT_COLORS, CONF_CUSTOM_EFFECT_SPEED_PCT, CONF_CUSTOM_EFFECT_TRANSITION, - CONF_EFFECT, CONF_SPEED_PCT, CONF_TRANSITION, CONF_WHITE_CHANNEL_TYPE, @@ -55,6 +54,7 @@ ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_EFFECT, CONF_HOST, CONF_MODE, CONF_NAME, diff --git a/tests/components/flux_led/test_select.py b/tests/components/flux_led/test_select.py index c8fd64c6811f75..1cdbb9369ab087 100644 --- a/tests/components/flux_led/test_select.py +++ b/tests/components/flux_led/test_select.py @@ -14,6 +14,7 @@ from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -133,7 +134,7 @@ async def test_select_addressable_strip_config(hass: HomeAssistant) -> None: state = hass.states.get(ic_type_entity_id) assert state.state == "WS2812B" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -149,7 +150,7 @@ async def test_select_addressable_strip_config(hass: HomeAssistant) -> None: bulb.async_set_device_config.assert_called_once_with(wiring="GRBW") bulb.async_set_device_config.reset_mock() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -191,7 +192,7 @@ async def test_select_mutable_0x25_strip_config(hass: HomeAssistant) -> None: state = hass.states.get(operating_mode_entity_id) assert state.state == "RGBWW" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -226,7 +227,7 @@ async def test_select_24ghz_remote_config(hass: HomeAssistant) -> None: state = hass.states.get(remote_config_entity_id) assert state.state == "Open" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -275,7 +276,7 @@ async def test_select_white_channel_type(hass: HomeAssistant) -> None: state = hass.states.get(operating_mode_entity_id) assert state.state == WhiteChannelType.WARM.name.title() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr index a009105e2e6b44..43145bcef9e9e5 100644 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -8,6 +8,7 @@ 'disabled_by': None, 'domain': 'forecast_solar', 'entry_id': , + 'minor_version': 1, 'options': dict({ 'api_key': 'abcdef12345', 'azimuth': 190, diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 8a6590d1105670..3ba175cbc7528c 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 @@ DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, DATA_HOME_GET_NODES, - DATA_HOME_PIR_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 @@ -41,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) @@ -79,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_PIR_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 a7dd313271926c..ae07b39c5e8561 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -21,11 +21,15 @@ DATA_STORAGE_GET_RAIDS = load_json_array_fixture("freebox/storage_get_raids.json") # switch -WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture("freebox/wifi_get_global_config.json") +DATA_WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture( + "freebox/wifi_get_global_config.json" +) # device_tracker 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 @@ -33,10 +37,14 @@ # Home # PIR node id 26, endpoint id 6 -DATA_HOME_PIR_GET_VALUES = load_json_object_fixture("freebox/home_pir_get_values.json") +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_VALUES = load_json_object_fixture( - "freebox/home_alarm_get_values.json" +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/home_alarm_get_values.json b/tests/components/freebox/fixtures/home_alarm_get_value.json similarity index 64% rename from tests/components/freebox/fixtures/home_alarm_get_values.json rename to tests/components/freebox/fixtures/home_alarm_get_value.json index 1e43a428296dea..6e4ad4d0538c1a 100644 --- a/tests/components/freebox/fixtures/home_alarm_get_values.json +++ b/tests/components/freebox/fixtures/home_alarm_get_value.json @@ -1,5 +1,5 @@ { "refresh": 2000, - "value": "alarm2_armed", + "value": "alarm1_armed", "value_type": "string" } diff --git a/tests/components/freebox/fixtures/home_pir_get_values.json b/tests/components/freebox/fixtures/home_pir_get_value.json similarity index 100% rename from tests/components/freebox/fixtures/home_pir_get_values.json rename to tests/components/freebox/fixtures/home_pir_get_value.json 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 00000000000000..5550c6db40a813 --- /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_bridge.json b/tests/components/freebox/fixtures/lan_get_hosts_list_bridge.json new file mode 100644 index 00000000000000..4afda46571255f --- /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/test_alarm_control_panel.py b/tests/components/freebox/test_alarm_control_panel.py index d24c747f2a3fbb..44286f18b87e6d 100644 --- a/tests/components/freebox/test_alarm_control_panel.py +++ b/tests/components/freebox/test_alarm_control_panel.py @@ -1,57 +1,68 @@ -"""Tests for the Freebox sensors.""" +"""Tests for the Freebox alarms.""" from copy import deepcopy from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -import pytest from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL, + 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_CUSTOM_BYPASS, SERVICE_ALARM_ARM_HOME, - SERVICE_ALARM_ARM_NIGHT, - SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_ARMED_VACATION, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, + STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.state import async_reproduce_state +from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_ALARM_GET_VALUES +from .const import DATA_HOME_ALARM_GET_VALUE, DATA_HOME_GET_NODES -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import async_fire_time_changed -async def test_panel( +async def test_alarm_changed_from_external( hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock ) -> None: - """Test home binary sensors.""" - await setup_platform(hass, ALARM_CONTROL_PANEL) + """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 == "unknown" assert ( - hass.states.get("alarm_control_panel.systeme_d_alarme").attributes[ - "supported_features" - ] - == AlarmControlPanelEntityFeature.ARM_AWAY + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMING ) # Now simulate a changed status - data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUES) + data_get_home_endpoint_value["value"] = "alarm1_armed" router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value # Simulate an update @@ -60,64 +71,105 @@ async def test_panel( await hass.async_block_till_done() assert ( - hass.states.get("alarm_control_panel.systeme_d_alarme").state == "armed_night" + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMED_AWAY ) - # Fake that the entity is triggered. - hass.states.async_set("alarm_control_panel.systeme_d_alarme", STATE_ALARM_DISARMED) - assert hass.states.get("alarm_control_panel.systeme_d_alarme").state == "disarmed" -async def test_reproducing_states( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test reproducing Alarm control panel states.""" - hass.states.async_set( - "alarm_control_panel.entity_armed_away", 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 ) - hass.states.async_set( - "alarm_control_panel.entity_armed_custom_bypass", - STATE_ALARM_ARMED_CUSTOM_BYPASS, - {}, + + # Initial state: arm_away + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMED_AWAY ) - hass.states.async_set( - "alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME, {} + + # 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, ) - hass.states.async_set( - "alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT, {} + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_DISARMED ) - hass.states.async_set( - "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION, {} + + # 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, ) - hass.states.async_set( - "alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED, {} + + 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, ) - hass.states.async_set( - "alarm_control_panel.entity_triggered", STATE_ALARM_TRIGGERED, {} + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_TRIGGERED ) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_AWAY) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_CUSTOM_BYPASS) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_HOME) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_NIGHT) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_VACATION) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_DISARM) - async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_TRIGGER) - - # These calls should do nothing as entities already in desired state - await async_reproduce_state( - hass, - [ - State("alarm_control_panel.entity_armed_away", STATE_ALARM_ARMED_AWAY), - State( - "alarm_control_panel.entity_armed_custom_bypass", - STATE_ALARM_ARMED_CUSTOM_BYPASS, - ), - State("alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME), - State("alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT), - State( - "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION - ), - State("alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED), - State("alarm_control_panel.entity_triggered", 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 2fd308ea667c49..ee07af786be725 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.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_PIR_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_PIR_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 5f72b5968f1b3d..209ab1e9fc216a 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 00000000000000..6d4ca5fb7eeba7 --- /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 85acfdccc4df1c..9064727fb7f262 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 00000000000000..572c168e665aba --- /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/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index bb34af7c4007cc..ded7cda0dea7d7 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/fritz/test_image.py b/tests/components/fritz/test_image.py index cbcbded56920af..da5b8a76d27dab 100644 --- a/tests/components/fritz/test_image.py +++ b/tests/components/fritz/test_image.py @@ -4,12 +4,13 @@ from unittest.mock import patch import pytest +from requests.exceptions import ReadTimeout from syrupy.assertion import SnapshotAssertion from homeassistant.components.fritz.const import DOMAIN from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry from homeassistant.setup import async_setup_component @@ -170,3 +171,43 @@ async def test_image_update( assert resp_body != resp_body_new assert resp_body_new == snapshot + + +@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES, **GUEST_WIFI_ENABLED})]) +async def test_image_update_unavailable( + hass: HomeAssistant, + fc_class_mock, + fh_class_mock, +) -> None: + """Test image update when fritzbox is unavailable.""" + + # setup component with image platform only + with patch( + "homeassistant.components.fritz.PLATFORMS", + [Platform.IMAGE], + ): + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + state = hass.states.get("image.mock_title_guestwifi") + assert state + + # fritzbox becomes unavailable + fc_class_mock().call_action_side_effect(ReadTimeout) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + state = hass.states.get("image.mock_title_guestwifi") + assert state.state == STATE_UNKNOWN + + # fritzbox is available again + fc_class_mock().call_action_side_effect(None) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + state = hass.states.get("image.mock_title_guestwifi") + assert state.state != STATE_UNKNOWN diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 15ff04f372078c..1faf37c84eebd4 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 ac6b702147a805..983516bb9c0ccc 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -21,7 +21,7 @@ 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 9c53c895f5d9ea..8c0bbec573e461 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 @@ 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 d49b5710a128cb..a14c53d65297c6 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -41,7 +41,7 @@ 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 af725ce93da151..e3a6d786abfca5 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 @@ 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 5c8d30772f0b74..b8273204325027 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -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 5511b93ac3f84f..858b564cd189e4 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -29,7 +29,7 @@ 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 b363d966c0132b..9fe25d02ed05eb 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -18,7 +18,7 @@ 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 @@ -108,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 4ed1a88190ac63..aefe21e3ffc35a 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -31,7 +31,7 @@ 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 @@ -187,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/__init__.py b/tests/components/fronius/__init__.py index c64972b7904950..2e053f7ccc5b00 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -37,7 +37,7 @@ async def setup_fronius_integration( def _load_and_patch_fixture( - override_data: dict[str, list[tuple[list[str], Any]]] + override_data: dict[str, list[tuple[list[str], Any]]], ) -> Callable[[str, str | None], str]: """Return a fixture loader that patches values at nested keys for a given filename.""" @@ -125,3 +125,17 @@ async def enable_all_entities(hass, freezer, config_entry_id, time_till_next_upd freezer.tick(time_till_next_update) async_fire_time_changed(hass) await hass.async_block_till_done() + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py index cc56fea24b28fb..f8d86bac26acdc 100644 --- a/tests/components/fronius/test_init.py +++ b/tests/components/fronius/test_init.py @@ -7,13 +7,15 @@ from homeassistant.components.fronius.const import DOMAIN, SOLAR_NET_RESCAN_TIMER from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import mock_responses, setup_fronius_integration +from . import mock_responses, remove_device, setup_fronius_integration from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator async def test_unload_config_entry( @@ -138,3 +140,29 @@ async def test_inverter_rescan_interruption( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == 2 ) + + +async def test_device_remove_devices( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we can remove a device.""" + assert await async_setup_component(hass, "config", {}) + + mock_responses(aioclient_mock, fixture_set="gen24_storage") + config_entry = await setup_fronius_integration( + hass, is_logger=False, unique_id="12345678" + ) + + inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "12345678")}) + assert ( + await remove_device( + await hass_ws_client(hass), inverter_1.id, config_entry.entry_id + ) + is True + ) + + assert not device_registry.async_get_device(identifiers={(DOMAIN, "12345678")}) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index f94b0f3a55c1bd..a8f48ce2e8800b 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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) @@ -322,6 +370,7 @@ def assert_state(entity_id, expected_state): async def test_gen24_storage( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Gen24 inverter with BYD battery and Ohmpilot entities.""" @@ -336,14 +385,14 @@ def assert_state(entity_id, expected_state): 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 +401,7 @@ def assert_state(entity_id, expected_state): 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 +413,8 @@ def assert_state(entity_id, expected_state): 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 +447,7 @@ def assert_state(entity_id, expected_state): 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) @@ -415,8 +466,6 @@ def assert_state(entity_id, expected_state): assert_state("sensor.byd_battery_box_premium_hv_dc_voltage", 0.0) # Devices - device_registry = dr.async_get(hass) - solar_net = device_registry.async_get_device( identifiers={(DOMAIN, "solar_net_12345678")} ) @@ -451,6 +500,7 @@ def assert_state(entity_id, expected_state): async def test_primo_s0( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Primo dual inverter with S0 meter entities.""" @@ -463,14 +513,14 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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 @@ def assert_state(entity_id, expected_state): 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) @@ -518,8 +573,6 @@ def assert_state(entity_id, expected_state): assert_state("sensor.solarnet_energy_year", 11128933.25) # Devices - device_registry = dr.async_get(hass) - solar_net = device_registry.async_get_device( identifiers={(DOMAIN, "solar_net_123.4567890")} ) diff --git a/tests/components/frontier_silicon/conftest.py b/tests/components/frontier_silicon/conftest.py index 40a6df853106ed..1def9b160b22f8 100644 --- a/tests/components/frontier_silicon/conftest.py +++ b/tests/components/frontier_silicon/conftest.py @@ -4,11 +4,8 @@ import pytest -from homeassistant.components.frontier_silicon.const import ( - CONF_PIN, - CONF_WEBFSAPI_URL, - DOMAIN, -) +from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN +from homeassistant.const import CONF_PIN from tests.common import MockConfigEntry diff --git a/tests/components/fully_kiosk/conftest.py b/tests/components/fully_kiosk/conftest.py index bed08b532fdeba..e409a0a37875ae 100644 --- a/tests/components/fully_kiosk/conftest.py +++ b/tests/components/fully_kiosk/conftest.py @@ -8,7 +8,13 @@ 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_config_flow.py b/tests/components/fully_kiosk/test_config_flow.py index 566f3b6d292269..018a62b5dc7135 100644 --- a/tests/components/fully_kiosk/test_config_flow.py +++ b/tests/components/fully_kiosk/test_config_flow.py @@ -10,7 +10,13 @@ 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_init.py b/tests/components/fully_kiosk/test_init.py index 5c77b8a9d065f2..2e77cdb2f1d3f8 100644 --- a/tests/components/fully_kiosk/test_init.py +++ b/tests/components/fully_kiosk/test_init.py @@ -9,7 +9,13 @@ 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 @@ -92,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", ) @@ -105,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_switch.py b/tests/components/fully_kiosk/test_switch.py index 4cbdad8d63a03b..3c0874384c276f 100644 --- a/tests/components/fully_kiosk/test_switch.py +++ b/tests/components/fully_kiosk/test_switch.py @@ -7,7 +7,8 @@ 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( @@ -86,6 +87,67 @@ 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", + '{"deviceId": "abcdef-123456","event": "onScreensaverStart"}', + ) + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity.state == "on" + + async_fire_mqtt_message( + hass, + "fully/event/onScreensaverStop/abcdef-123456", + '{"deviceId": "abcdef-123456","event": "onScreensaverStop"}', + ) + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity.state == "off" + + async_fire_mqtt_message( + hass, + "fully/event/screenOff/abcdef-123456", + '{"deviceId": "abcdef-123456","event": "screenOff"}', + ) + entity = hass.states.get("switch.amazon_fire_screen") + assert entity.state == "off" + + async_fire_mqtt_message( + hass, + "fully/event/screenOn/abcdef-123456", + '{"deviceId": "abcdef-123456","event": "screenOn"}', + ) + 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/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 31925e2d626cac..a2fe4b63cf833d 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -31,6 +31,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'gardena_bluetooth', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -40,6 +41,7 @@ 'disabled_by': None, 'domain': 'gardena_bluetooth', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -238,6 +240,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'gardena_bluetooth', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -247,6 +250,7 @@ 'disabled_by': None, 'domain': 'gardena_bluetooth', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index da318b1a94da5f..670d3efce51c64 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -72,10 +72,10 @@ async def test_setup(hass: HomeAssistant) -> None: == 4 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") assert state is not None assert int(state.state) == 3 - assert state.name == "GDACS (32.87336, -117.22743)" + assert state.name == "32.87336, -117.22743" attributes = state.attributes assert attributes[ATTR_STATUS] == "OK" assert attributes[ATTR_CREATED] == 3 @@ -96,7 +96,7 @@ async def test_setup(hass: HomeAssistant) -> None: == 4 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") attributes = state.attributes assert attributes[ATTR_CREATED] == 1 assert attributes[ATTR_UPDATED] == 2 @@ -125,6 +125,6 @@ async def test_setup(hass: HomeAssistant) -> None: == 1 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") attributes = state.attributes assert attributes[ATTR_REMOVED] == 3 diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index aecfcbc29c19fd..70746f70c9aec6 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,10 +1,11 @@ """The tests for generic camera component.""" import asyncio +from datetime import timedelta from http import HTTPStatus -import sys from unittest.mock import patch import aiohttp +from freezegun.api import FrozenDateTimeFactory import httpx import pytest import respx @@ -50,6 +51,7 @@ async def test_fetching_url( "username": "user", "password": "pass", "authentication": "basic", + "framerate": 20, } }, ) @@ -64,7 +66,84 @@ async def test_fetching_url( body = await resp.read() assert body == fakeimgbytes_png + # sleep .1 seconds to make cached image expire + await asyncio.sleep(0.1) + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert respx.calls.call_count == 2 + + +@respx.mock +async def test_image_caching( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + fakeimgbytes_png, +) -> None: + """Test that the image is cached and not fetched more often than the framerate indicates.""" + respx.get("http://example.com").respond(stream=fakeimgbytes_png) + + framerate = 5 + await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "authentication": "basic", + "framerate": framerate, + } + }, + ) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # time is frozen, image should have come from cache + assert respx.calls.call_count == 1 + + # advance time by 150ms + freezer.tick(timedelta(seconds=0.150)) + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # Only 150ms have passed, image should still have come from cache + assert respx.calls.call_count == 1 + + # advance time by another 150ms + freezer.tick(timedelta(seconds=0.150)) + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # 300ms have passed, now we should have fetched a new image + assert respx.calls.call_count == 2 + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # Still only 300ms have passed, should have returned the cached image assert respx.calls.call_count == 2 @@ -164,17 +243,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 @@ -476,6 +548,7 @@ async def test_timeout_cancelled( "still_image_url": "http://example.com", "username": "user", "password": "pass", + "framerate": 20, } }, ) @@ -505,6 +578,8 @@ async def test_timeout_cancelled( ] for total_calls in range(2, 4): + # sleep .1 seconds to make cached image expire + await asyncio.sleep(0.1) resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == total_calls assert resp.status == HTTPStatus.OK diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 47a3cdc30af941..9196de8b0963f1 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -41,6 +41,7 @@ State, callback, ) +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -388,7 +389,7 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, setup_comp_2) -> Non await common.async_set_preset_mode(hass, "none") state = hass.states.get(ENTITY) assert state.attributes.get("preset_mode") == "none" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_preset_mode(hass, "Sleep") state = hass.states.get(ENTITY) assert state.attributes.get("preset_mode") == "none" diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index a44357a576341d..3875a525e73fac 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -20,7 +20,7 @@ CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, - LENGTH_KILOMETERS, + UnitOfLength, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -77,7 +77,7 @@ async def test_entity_lifecycle( ATTR_LATITUDE: -31.0, ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", - ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } assert round(abs(float(state.state) - 15.5), 7) == 0 @@ -90,7 +90,7 @@ async def test_entity_lifecycle( ATTR_LATITUDE: -31.1, ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", - ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } assert round(abs(float(state.state) - 20.5), 7) == 0 @@ -103,7 +103,7 @@ async def test_entity_lifecycle( ATTR_LATITUDE: -31.2, ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", - ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } assert round(abs(float(state.state) - 25.5), 7) == 0 diff --git a/tests/components/geo_rss_events/test_sensor.py b/tests/components/geo_rss_events/test_sensor.py index 02225df3755acf..c86ef393875b4e 100644 --- a/tests/components/geo_rss_events/test_sensor.py +++ b/tests/components/geo_rss_events/test_sensor.py @@ -1,6 +1,7 @@ """The test for the geo rss events sensor platform.""" from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import sensor @@ -56,7 +57,9 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant, mock_feed) -> None: +async def test_setup( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_feed +) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -68,10 +71,8 @@ async def test_setup(hass: HomeAssistant, mock_feed) -> None: mock_feed.return_value.update.return_value = "OK", [mock_entry_1, mock_entry_2] utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch( - "homeassistant.util.dt.utcnow", return_value=utcnow - ), assert_setup_component(1, sensor.DOMAIN): + freezer.move_to(utcnow) + with assert_setup_component(1, sensor.DOMAIN): assert await async_setup_component(hass, sensor.DOMAIN, VALID_CONFIG) # Artificially trigger update. hass.bus.fire(EVENT_HOMEASSISTANT_START) diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 561d9aaedeb197..afc6ada75cd280 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -2,6 +2,8 @@ import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components import geonetnz_quakes from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED @@ -38,7 +40,11 @@ CONFIG = {geonetnz_quakes.DOMAIN: {CONF_RADIUS: 200}} -async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: """Test the general setup of the integration.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -64,9 +70,8 @@ async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update: + freezer.move_to(utcnow) + with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update: mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG) await hass.async_block_till_done() @@ -167,17 +172,17 @@ async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> assert len(entity_registry.entities) == 1 -async def test_setup_imperial(hass: HomeAssistant) -> None: +async def test_setup_imperial( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the setup of the integration using imperial unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (38.0, -3.0)) # Patching 'utcnow' to gain more control over the timed update. - utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update, patch( + freezer.move_to(dt_util.utcnow()) + with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update, patch( "aio_geojson_client.feed.GeoJsonFeed.last_timestamp", create=True ): mock_feed_update.return_value = "OK", [mock_entry_1] diff --git a/tests/components/geonetnz_volcano/test_sensor.py b/tests/components/geonetnz_volcano/test_sensor.py index a237fb2c314b64..4d11ff0673cfbf 100644 --- a/tests/components/geonetnz_volcano/test_sensor.py +++ b/tests/components/geonetnz_volcano/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from homeassistant.components import geonetnz_volcano from homeassistant.components.geo_location import ATTR_DISTANCE @@ -149,15 +150,17 @@ async def test_setup(hass: HomeAssistant) -> None: ) -async def test_setup_imperial(hass: HomeAssistant) -> None: +async def test_setup_imperial( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the setup of the integration using imperial unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 1, 15.5, (38.0, -3.0)) # Patching 'utcnow' to gain more control over the timed update. - utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + freezer.move_to(dt_util.utcnow()) + with patch( "aio_geojson_client.feed.GeoJsonFeed.update", new_callable=AsyncMock ) as mock_feed_update, patch( "aio_geojson_client.feed.GeoJsonFeed.__init__" diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py index 946cceac78638a..4e69420f66e7d4 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/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 67691602fcf253..1401b1e22a0821 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'gios', 'entry_id': '86129426118ae32020417a53712d6eef', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index 3d52c122791a16..efe46be9b8d43d 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 0d4484c6d0d1fd..d20aecad3df7da 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( diff --git a/tests/components/github/conftest.py b/tests/components/github/conftest.py index 04b53da6b91a5e..b0b6f243fa0a4a 100644 --- a/tests/components/github/conftest.py +++ b/tests/components/github/conftest.py @@ -4,11 +4,8 @@ import pytest -from homeassistant.components.github.const import ( - CONF_ACCESS_TOKEN, - CONF_REPOSITORIES, - DOMAIN, -) +from homeassistant.components.github.const import CONF_REPOSITORIES, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from .common import MOCK_ACCESS_TOKEN, TEST_REPOSITORY, setup_github_integration diff --git a/tests/components/github/test_config_flow.py b/tests/components/github/test_config_flow.py index ad3be582a5dc38..8d61eca1ab1bc7 100644 --- a/tests/components/github/test_config_flow.py +++ b/tests/components/github/test_config_flow.py @@ -2,17 +2,18 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiogithubapi import GitHubException +import pytest from homeassistant import config_entries from homeassistant.components.github.config_flow import get_repositories from homeassistant.components.github.const import ( - CONF_ACCESS_TOKEN, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, ) +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, UnknownFlow from .common import MOCK_ACCESS_TOKEN @@ -126,6 +127,44 @@ async def test_flow_with_activation_failure( assert result["step_id"] == "could_not_register" +async def test_flow_with_remove_while_activating( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test flow with user canceling while activating.""" + aioclient_mock.post( + "https://github.com/login/device/code", + json={ + "device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5", + "user_code": "WDJB-MJHT", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5, + }, + headers={"Content-Type": "application/json"}, + ) + aioclient_mock.post( + "https://github.com/login/oauth/access_token", + json={"error": "authorization_pending"}, + headers={"Content-Type": "application/json"}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["step_id"] == "device" + assert result["type"] == FlowResultType.SHOW_PROGRESS + + assert hass.config_entries.flow.async_get(result["flow_id"]) + + # Simulate user canceling the flow + hass.config_entries.flow._async_remove_flow_progress(result["flow_id"]) + await hass.async_block_till_done() + + with pytest.raises(UnknownFlow): + hass.config_entries.flow.async_get(result["flow_id"]) + + async def test_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 41f2675c41ccee..f0f1fe01796423 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -6,7 +6,6 @@ "host": "0.0.0.0", "username": "username", "password": "password", - "version": 3, "port": 61208, "ssl": False, "verify_ssl": True, @@ -181,8 +180,8 @@ }, "sensors": { "cpu_thermal 1": {"temperature_core": 59}, - "err_temp": {"temperature_hdd": "Unavailable"}, - "na_temp": {"temperature_hdd": "Unavailable"}, + "err_temp": {"temperature_hdd": "unavailable"}, + "na_temp": {"temperature_hdd": "unavailable"}, }, "mem": { "memory_use_percent": 27.6, diff --git a/tests/components/glances/snapshots/test_sensor.ambr b/tests/components/glances/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..0dbdec5471473a --- /dev/null +++ b/tests/components/glances/snapshots/test_sensor.ambr @@ -0,0 +1,915 @@ +# serializer version: 1 +# name: test_sensor_states[sensor.0_0_0_0_containers_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_containers_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:docker', + 'original_name': 'Containers active', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--docker_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 Containers active', + 'icon': 'mdi:docker', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_containers_active', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_cpu_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_containers_cpu_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:docker', + 'original_name': 'Containers CPU used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--docker_cpu_use', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_cpu_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 Containers CPU used', + 'icon': 'mdi:docker', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_containers_cpu_used', + 'last_changed': , + 'last_updated': , + 'state': '77.2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_ram_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_containers_ram_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:docker', + 'original_name': 'Containers RAM used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--docker_memory_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_ram_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 Containers RAM used', + 'icon': 'mdi:docker', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_containers_ram_used', + 'last_changed': , + 'last_updated': , + 'state': '1149.6', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_cpu_thermal_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_cpu_thermal_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'cpu_thermal 1 Temperature', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-cpu_thermal 1-temperature_core', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_cpu_thermal_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '0.0.0.0 cpu_thermal 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_cpu_thermal_1_temperature', + 'last_changed': , + 'last_updated': , + 'state': '59', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_err_temp_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_err_temp_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'err_temp Temperature', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-err_temp-temperature_hdd', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_err_temp_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '0.0.0.0 err_temp Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_err_temp_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_raid_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_md1_raid_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': 'md1 Raid available', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-md1-available', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_raid_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md1 Raid available', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md1_raid_available', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_raid_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_md1_raid_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': 'md1 Raid used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-md1-used', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_raid_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md1 Raid used', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md1_raid_used', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_raid_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_md3_raid_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': 'md3 Raid available', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-md3-available', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_raid_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md3 Raid available', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md3_raid_available', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_raid_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_md3_raid_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': 'md3 Raid used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-md3-used', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_raid_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md3 Raid used', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md3_raid_used', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_free-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_media_free', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:harddisk', + 'original_name': '/media free', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/media-disk_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_free-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /media free', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_media_free', + 'last_changed': , + 'last_updated': , + 'state': '426.5', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_media_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:harddisk', + 'original_name': '/media used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/media-disk_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /media used', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_media_used', + 'last_changed': , + 'last_updated': , + 'state': '30.7', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_used_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_media_used_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': '/media used percent', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/media-disk_use_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_used_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 /media used percent', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_media_used_percent', + 'last_changed': , + 'last_updated': , + 'state': '6.7', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_na_temp_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_na_temp_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'na_temp Temperature', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-na_temp-temperature_hdd', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_na_temp_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '0.0.0.0 na_temp Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_na_temp_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_free-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_ram_free', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:memory', + 'original_name': 'RAM free', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--memory_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_free-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 RAM free', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ram_free', + 'last_changed': , + 'last_updated': , + 'state': '2745.0', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_ram_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:memory', + 'original_name': 'RAM used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--memory_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 RAM used', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ram_used', + 'last_changed': , + 'last_updated': , + 'state': '1047.1', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_used_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_ram_used_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:memory', + 'original_name': 'RAM used percent', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--memory_use_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_used_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 RAM used percent', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ram_used_percent', + 'last_changed': , + 'last_updated': , + 'state': '27.6', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_free-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_ssl_free', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:harddisk', + 'original_name': '/ssl free', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/ssl-disk_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_free-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /ssl free', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ssl_free', + 'last_changed': , + 'last_updated': , + 'state': '426.5', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_ssl_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:harddisk', + 'original_name': '/ssl used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/ssl-disk_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /ssl used', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ssl_used', + 'last_changed': , + 'last_updated': , + 'state': '30.7', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_used_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_ssl_used_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': '/ssl used percent', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/ssl-disk_use_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_used_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 /ssl used percent', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ssl_used_percent', + 'last_changed': , + 'last_updated': , + 'state': '6.7', + }) +# --- diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index 87ec80da057b45..8d590317c619df 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -4,6 +4,7 @@ from glances_api.exceptions import ( GlancesApiAuthorizationError, GlancesApiConnectionError, + GlancesApiNoDataAvailable, ) import pytest @@ -47,6 +48,7 @@ async def test_form(hass: HomeAssistant) -> None: [ (GlancesApiAuthorizationError, "invalid_auth"), (GlancesApiConnectionError, "cannot_connect"), + (GlancesApiNoDataAvailable, "cannot_connect"), ], ) async def test_form_fails( @@ -54,7 +56,7 @@ async def test_form_fails( ) -> None: """Test flow fails when api exception is raised.""" - mock_api.return_value.get_ha_sensor_data.side_effect = [error, HA_SENSOR_DATA] + mock_api.return_value.get_ha_sensor_data.side_effect = error result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -65,12 +67,6 @@ async def test_form_fails( assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": message} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_INPUT - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - async def test_form_already_configured(hass: HomeAssistant) -> None: """Test host is already configured.""" diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 61cbc610060950..764426c6276fa9 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -1,17 +1,19 @@ """Tests for Glances integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from glances_api.exceptions import ( GlancesApiAuthorizationError, GlancesApiConnectionError, + GlancesApiNoDataAvailable, ) import pytest from homeassistant.components.glances.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir -from . import MOCK_USER_INPUT +from . import HA_SENSOR_DATA, MOCK_USER_INPUT from tests.common import MockConfigEntry @@ -27,11 +29,34 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.LOADED +async def test_entry_deprecated_version( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api: AsyncMock +) -> None: + """Test creating an issue if glances server is version 2.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + mock_api.return_value.get_ha_sensor_data.side_effect = [ + GlancesApiNoDataAvailable("endpoint: 'all' is not valid"), + HA_SENSOR_DATA, + HA_SENSOR_DATA, + ] + + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state == ConfigEntryState.LOADED + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_version") + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING + + @pytest.mark.parametrize( ("error", "entry_state"), [ (GlancesApiAuthorizationError, ConfigEntryState.SETUP_ERROR), (GlancesApiConnectionError, ConfigEntryState.SETUP_RETRY), + (GlancesApiNoDataAvailable, ConfigEntryState.SETUP_ERROR), ], ) async def test_setup_error( diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index 095c034abe0e3b..7369bb927ffc16 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -1,48 +1,34 @@ """Tests for glances sensors.""" import pytest +from syrupy import SnapshotAssertion from homeassistant.components.glances.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 . import HA_SENSOR_DATA, MOCK_USER_INPUT +from . import MOCK_USER_INPUT from tests.common import MockConfigEntry -async def test_sensor_states(hass: HomeAssistant) -> None: +async def test_sensor_states( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Test sensor states are correctly collected from library.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, entry_id="test") entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - if state := hass.states.get("sensor.0_0_0_0_ssl_disk_use"): - assert state.state == HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] - if state := hass.states.get("sensor.0_0_0_0_cpu_thermal_1"): - assert state.state == HA_SENSOR_DATA["sensors"]["cpu_thermal 1"] - if state := hass.states.get("sensor.0_0_0_0_err_temp"): - assert state.state == HA_SENSOR_DATA["sensors"]["err_temp"] - if state := hass.states.get("sensor.0_0_0_0_na_temp"): - assert state.state == HA_SENSOR_DATA["sensors"]["na_temp"] - if state := hass.states.get("sensor.0_0_0_0_memory_use_percent"): - assert state.state == HA_SENSOR_DATA["mem"]["memory_use_percent"] - if state := hass.states.get("sensor.0_0_0_0_docker_active"): - assert state.state == HA_SENSOR_DATA["docker"]["docker_active"] - if state := hass.states.get("sensor.0_0_0_0_docker_cpu_use"): - assert state.state == HA_SENSOR_DATA["docker"]["docker_cpu_use"] - if state := hass.states.get("sensor.0_0_0_0_docker_memory_use"): - assert state.state == HA_SENSOR_DATA["docker"]["docker_memory_use"] - if state := hass.states.get("sensor.0_0_0_0_md3_available"): - assert state.state == HA_SENSOR_DATA["raid"]["md3"]["available"] - if state := hass.states.get("sensor.0_0_0_0_md3_used"): - assert state.state == HA_SENSOR_DATA["raid"]["md3"]["used"] - if state := hass.states.get("sensor.0_0_0_0_md1_available"): - assert state.state == HA_SENSOR_DATA["raid"]["md1"]["available"] - if state := hass.states.get("sensor.0_0_0_0_md1_used"): - assert state.state == HA_SENSOR_DATA["raid"]["md1"]["used"] + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) @pytest.mark.parametrize( diff --git a/tests/components/goodwe/conftest.py b/tests/components/goodwe/conftest.py new file mode 100644 index 00000000000000..cabb0f6ea10464 --- /dev/null +++ b/tests/components/goodwe/conftest.py @@ -0,0 +1,25 @@ +"""Fixtures for the Aladdin Connect integration tests.""" +from unittest.mock import AsyncMock, MagicMock + +from goodwe import Inverter +import pytest + + +@pytest.fixture(name="mock_inverter") +def fixture_mock_inverter(): + """Set up inverter fixture.""" + mock_inverter = MagicMock(spec=Inverter) + mock_inverter.serial_number = "dummy_serial_nr" + mock_inverter.arm_version = 1 + mock_inverter.arm_svn_version = 2 + mock_inverter.arm_firmware = "dummy.arm.version" + mock_inverter.firmware = "dummy.fw.version" + mock_inverter.model_name = "MOCK" + mock_inverter.rated_power = 10000 + mock_inverter.dsp1_version = 3 + mock_inverter.dsp2_version = 4 + mock_inverter.dsp_svn_version = 5 + + mock_inverter.read_runtime_data = AsyncMock(return_value={}) + + return mock_inverter diff --git a/tests/components/goodwe/snapshots/test_diagnostics.ambr b/tests/components/goodwe/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..4097848a34a255 --- /dev/null +++ b/tests/components/goodwe/snapshots/test_diagnostics.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': 'localhost', + 'model_family': 'ET', + }), + 'disabled_by': None, + 'domain': 'goodwe', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + 'inverter': dict({ + 'arm_firmware': 'dummy.arm.version', + 'arm_svn_version': 2, + 'arm_version': 1, + 'dsp1_version': 3, + 'dsp2_version': 4, + 'dsp_svn_version': 5, + 'firmware': 'dummy.fw.version', + 'model_name': 'MOCK', + 'rated_power': 10000, + }), + }) +# --- diff --git a/tests/components/goodwe/test_diagnostics.py b/tests/components/goodwe/test_diagnostics.py new file mode 100644 index 00000000000000..edda2ed2cb79a1 --- /dev/null +++ b/tests/components/goodwe/test_diagnostics.py @@ -0,0 +1,34 @@ +"""Test the CO2Signal diagnostics.""" +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.goodwe import CONF_MODEL_FAMILY, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_inverter: MagicMock, +) -> None: + """Test config entry diagnostics.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "localhost", CONF_MODEL_FAMILY: "ET"}, + entry_id="3bd2acb0e4f0476d40865546d0d91921", + ) + config_entry.add_to_hass(hass) + with patch("homeassistant.components.goodwe.connect", return_value=mock_inverter): + 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/google/conftest.py b/tests/components/google/conftest.py index 3b2ed6d24e19aa..97d918c2e01907 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable, Generator import datetime import http +import time from typing import Any, TypeVar from unittest.mock import Mock, mock_open, patch @@ -189,9 +190,9 @@ def creds( @pytest.fixture -def config_entry_token_expiry(token_expiry: datetime.datetime) -> float: +def config_entry_token_expiry() -> float: """Fixture for token expiration value stored in the config entry.""" - return token_expiry.timestamp() + return time.time() + 86400 @pytest.fixture @@ -260,7 +261,7 @@ def _put_result( @pytest.fixture def mock_events_list_items( - mock_events_list: Callable[[dict[str, Any]], None] + mock_events_list: Callable[[dict[str, Any]], None], ) -> Callable[[list[dict[str, Any]]], None]: """Fixture to construct an API response containing event items.""" diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 3617456c9e676d..d1cc41e166a93c 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -9,6 +9,7 @@ import urllib from aiohttp.client_exceptions import ClientError +from freezegun.api import FrozenDateTimeFactory from gcal_sync.auth import API_BASE_URL import pytest @@ -578,11 +579,13 @@ async def test_scan_calendar_error( async def test_future_event_update_behavior( - hass: HomeAssistant, mock_events_list_items, component_setup + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_events_list_items, + component_setup, ) -> None: """Test an future event that becomes active.""" now = dt_util.now() - now_utc = dt_util.utcnow() one_hour_from_now = now + datetime.timedelta(minutes=60) end_event = one_hour_from_now + datetime.timedelta(minutes=90) event = { @@ -600,12 +603,9 @@ async def test_future_event_update_behavior( # Advance time until event has started now += datetime.timedelta(minutes=60) - now_utc += datetime.timedelta(minutes=60) - with patch("homeassistant.util.dt.utcnow", return_value=now_utc), patch( - "homeassistant.util.dt.now", return_value=now - ): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # Event has started state = hass.states.get(TEST_ENTITY) @@ -613,11 +613,13 @@ async def test_future_event_update_behavior( async def test_future_event_offset_update_behavior( - hass: HomeAssistant, mock_events_list_items, component_setup + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_events_list_items, + component_setup, ) -> None: """Test an future event that becomes active.""" now = dt_util.now() - now_utc = dt_util.utcnow() one_hour_from_now = now + datetime.timedelta(minutes=60) end_event = one_hour_from_now + datetime.timedelta(minutes=90) event_summary = "Test Event in Progress" @@ -638,12 +640,9 @@ async def test_future_event_offset_update_behavior( # Advance time until event has started now += datetime.timedelta(minutes=45) - now_utc += datetime.timedelta(minutes=45) - with patch("homeassistant.util.dt.utcnow", return_value=now_utc), patch( - "homeassistant.util.dt.now", return_value=now - ): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # Event has not started, but the offset was reached state = hass.states.get(TEST_ENTITY) @@ -1299,3 +1298,52 @@ async def test_event_differs_timezone( "description": event["description"], "supported_features": 3, } + + +@pytest.mark.freeze_time("2023-11-30 12:15:00 +00:00") +async def test_invalid_rrule_fix( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_events_list_items, + component_setup, +) -> None: + """Test that an invalid RRULE returned from Google Calendar API is handled correctly end to end.""" + week_from_today = dt_util.now().date() + datetime.timedelta(days=7) + end_event = week_from_today + datetime.timedelta(days=1) + event = { + **TEST_EVENT, + "start": {"date": week_from_today.isoformat()}, + "end": {"date": end_event.isoformat()}, + "recurrence": [ + "RRULE:DATE;TZID=Europe/Warsaw:20230818T020000,20230915T020000,20231013T020000,20231110T010000,20231208T010000", + ], + } + mock_events_list_items([event]) + + assert await component_setup() + + state = hass.states.get(TEST_ENTITY) + assert state.name == TEST_ENTITY_NAME + assert state.state == STATE_OFF + + # Pick a date range that contains two instances of the event + web_client = await hass_client() + response = await web_client.get( + get_events_url(TEST_ENTITY, "2023-08-10T00:00:00Z", "2023-09-20T00:00:00Z") + ) + assert response.status == HTTPStatus.OK + events = await response.json() + + # Both instances are returned, however the RDATE rule is ignored by Home + # Assistant so they are just treateded as flattened events. + assert len(events) == 2 + + event = events[0] + assert event["uid"] == "cydrevtfuybguinhomj@google.com" + assert event["recurrence_id"] == "_c8rinwq863h45qnucyoi43ny8_20230818" + assert event["rrule"] is None + + event = events[1] + assert event["uid"] == "cydrevtfuybguinhomj@google.com" + assert event["recurrence_id"] == "_c8rinwq863h45qnucyoi43ny8_20230915" + assert event["rrule"] is None diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index f534f624bf6679..b2c472757b6916 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -9,6 +9,7 @@ from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError +from freezegun import freeze_time from oauth2client.client import ( DeviceFlowInfo, FlowExchangeError, @@ -130,7 +131,7 @@ async def primary_calendar( async def fire_alarm(hass, point_in_time): """Fire an alarm and wait for callbacks to run.""" - with patch("homeassistant.util.dt.utcnow", return_value=point_in_time): + with freeze_time(point_in_time): async_fire_time_changed(hass, point_in_time) await hass.async_block_till_done() diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 9ede0573922a83..26a5cb2e192d11 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -699,7 +699,11 @@ async def test_add_event_location( @pytest.mark.parametrize( "config_entry_token_expiry", - [datetime.datetime.max.replace(tzinfo=UTC).timestamp() + 1], + [ + (datetime.datetime.max.replace(tzinfo=UTC).timestamp() + 1), + (utcnow().replace(tzinfo=None).timestamp()), + ], + ids=["max_timestamp", "timestamp_naive"], ) async def test_invalid_token_expiry_in_config_entry( hass: HomeAssistant, diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 2122818bbb4422..6fc1c9f580dae2 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -237,6 +237,26 @@ def should_2fa(self, state): "type": "action.devices.types.SETTOP", "willReportState": False, }, + { + "id": "media_player.browse", + "name": {"name": "Browse"}, + "traits": ["action.devices.traits.MediaState", "action.devices.traits.OnOff"], + "type": "action.devices.types.SETTOP", + "willReportState": False, + }, + { + "id": "media_player.group", + "name": {"name": "Group"}, + "traits": [ + "action.devices.traits.OnOff", + "action.devices.traits.Volume", + "action.devices.traits.Modes", + "action.devices.traits.TransportControl", + "action.devices.traits.MediaState", + ], + "type": "action.devices.types.SETTOP", + "willReportState": False, + }, { "id": "fan.living_room_fan", "name": {"name": "Living Room Fan"}, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index dffcddf5de51a8..9a4ad8b3da3587 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -7,6 +7,7 @@ }), 'disabled_by': None, 'domain': 'google_assistant', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -102,6 +103,8 @@ 'sensor', 'switch', 'vacuum', + 'valve', + 'water_heater', ]), 'project_id': '1234', 'report_state': False, diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 57915968933c36..aaa3949caaf961 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -14,14 +14,17 @@ 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 62d2722c445399..aa7f8472cab96b 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/test_trait.py b/tests/components/google_assistant/test_trait.py index 903ba5ca03605e..3f1e28cb667206 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from unittest.mock import ANY, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import ( @@ -27,6 +28,8 @@ sensor, switch, vacuum, + valve, + water_heater, ) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.camera import CameraEntityFeature @@ -44,6 +47,8 @@ MediaType, ) from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.valve import ValveEntityFeature +from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -74,7 +79,8 @@ HomeAssistant, State, ) -from homeassistant.util import color +from homeassistant.util import color, dt as dt_util +from homeassistant.util.unit_conversion import TemperatureConverter from . import BASIC_CONFIG, MockConfig @@ -393,6 +399,35 @@ async def test_onoff_humidifier(hass: HomeAssistant) -> None: assert off_calls[0].data == {ATTR_ENTITY_ID: "humidifier.bla"} +async def test_onoff_water_heater(hass: HomeAssistant) -> None: + """Test OnOff trait support for water_heater domain.""" + assert helpers.get_google_type(water_heater.DOMAIN, None) is not None + assert trait.OnOffTrait.supported( + water_heater.DOMAIN, WaterHeaterEntityFeature.ON_OFF, None, None + ) + + trt_on = trait.OnOffTrait(hass, State("water_heater.bla", STATE_ON), BASIC_CONFIG) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == {"on": True} + + trt_off = trait.OnOffTrait(hass, State("water_heater.bla", STATE_OFF), BASIC_CONFIG) + + assert trt_off.query_attributes() == {"on": False} + + on_calls = async_mock_service(hass, water_heater.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(trait.COMMAND_ONOFF, BASIC_DATA, {"on": True}, {}) + assert len(on_calls) == 1 + assert on_calls[0].data == {ATTR_ENTITY_ID: "water_heater.bla"} + + off_calls = async_mock_service(hass, water_heater.DOMAIN, SERVICE_TURN_OFF) + + await trt_on.execute(trait.COMMAND_ONOFF, BASIC_DATA, {"on": False}, {}) + assert len(off_calls) == 1 + assert off_calls[0].data == {ATTR_ENTITY_ID: "water_heater.bla"} + + async def test_dock_vacuum(hass: HomeAssistant) -> None: """Test dock trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None @@ -549,17 +584,71 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: assert unpause_calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"} -async def test_startstop_cover(hass: HomeAssistant) -> None: - """Test startStop trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.StartStopTrait.supported( - cover.DOMAIN, CoverEntityFeature.STOP, None, None - ) +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "state_opening", + "state_closing", + "supported_features", + "service_close", + "service_open", + "service_stop", + "service_toggle", + ), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + cover.STATE_CLOSED, + cover.STATE_OPENING, + cover.STATE_CLOSING, + CoverEntityFeature.STOP + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_STOP_COVER, + cover.SERVICE_TOGGLE, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + valve.STATE_CLOSED, + valve.STATE_OPENING, + valve.STATE_CLOSING, + ValveEntityFeature.STOP + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_STOP_VALVE, + cover.SERVICE_TOGGLE, + ), + ], +) +async def test_startstop_cover_valve( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + state_opening: str, + state_closing: str, + supported_features: str, + service_open: str, + service_close: str, + service_stop: str, + service_toggle: str, +) -> None: + """Test startStop trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.StartStopTrait.supported(domain, supported_features, None, None) state = State( - "cover.bla", - cover.STATE_CLOSED, - {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.STOP}, + f"{domain}.bla", + state_closed, + {ATTR_SUPPORTED_FEATURES: supported_features}, ) trt = trait.StartStopTrait( @@ -570,25 +659,48 @@ async def test_startstop_cover(hass: HomeAssistant) -> None: assert trt.sync_attributes() == {} - for state_value in (cover.STATE_CLOSING, cover.STATE_OPENING): + for state_value in (state_closing, state_opening): state.state = state_value assert trt.query_attributes() == {"isRunning": True} - stop_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_STOP_COVER) + stop_calls = async_mock_service(hass, domain, service_stop) + open_calls = async_mock_service(hass, domain, service_open) + close_calls = async_mock_service(hass, domain, service_close) + toggle_calls = async_mock_service(hass, domain, service_toggle) await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) assert len(stop_calls) == 1 - assert stop_calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert stop_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} - for state_value in (cover.STATE_CLOSED, cover.STATE_OPEN): + for state_value in (state_closed, state_open): state.state = state_value assert trt.query_attributes() == {"isRunning": False} - with pytest.raises(SmartHomeError, match="Cover is already stopped"): + for state_value in (state_closing, state_opening): + state.state = state_value + assert trt.query_attributes() == {"isRunning": True} + + state.state = state_open + with pytest.raises( + SmartHomeError, match=f"{domain.capitalize()} is already stopped" + ): await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) - with pytest.raises(SmartHomeError, match="Starting a cover is not supported"): - await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + # Start triggers toggle open + state.state = state_closed + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + assert len(open_calls) == 0 + assert len(close_calls) == 0 + assert len(toggle_calls) == 1 + assert toggle_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + # Second start triggers toggle close + state.state = state_open + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + assert len(open_calls) == 0 + assert len(close_calls) == 0 + assert len(toggle_calls) == 2 + assert toggle_calls[1].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + state.state = state_closed with pytest.raises( SmartHomeError, match="Command action.devices.commands.PauseUnpause is not supported", @@ -596,25 +708,89 @@ async def test_startstop_cover(hass: HomeAssistant) -> None: await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {"start": True}, {}) -async def test_startstop_cover_assumed(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "state_opening", + "state_closing", + "supported_features", + "service_close", + "service_open", + "service_stop", + "service_toggle", + ), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + cover.STATE_CLOSED, + cover.STATE_OPENING, + cover.STATE_CLOSING, + CoverEntityFeature.STOP + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_STOP_COVER, + cover.SERVICE_TOGGLE, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + valve.STATE_CLOSED, + valve.STATE_OPENING, + valve.STATE_CLOSING, + ValveEntityFeature.STOP + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_STOP_VALVE, + cover.SERVICE_TOGGLE, + ), + ], +) +async def test_startstop_cover_valve_assumed( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + state_opening: str, + state_closing: str, + supported_features: str, + service_open: str, + service_close: str, + service_stop: str, + service_toggle: str, +) -> None: """Test startStop trait support for cover domain of assumed state.""" trt = trait.StartStopTrait( hass, State( - "cover.bla", - cover.STATE_CLOSED, + f"{domain}.bla", + state_closed, { - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.STOP, + ATTR_SUPPORTED_FEATURES: supported_features, ATTR_ASSUMED_STATE: True, }, ), BASIC_CONFIG, ) - stop_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_STOP_COVER) + stop_calls = async_mock_service(hass, domain, service_stop) + toggle_calls = async_mock_service(hass, domain, service_toggle) await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) assert len(stop_calls) == 1 - assert stop_calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert len(toggle_calls) == 0 + assert stop_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + + stop_calls.clear() + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + assert len(stop_calls) == 0 + assert len(toggle_calls) == 1 + assert toggle_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} @pytest.mark.parametrize("supported_color_modes", [["hs"], ["rgb"], ["xy"]]) @@ -1246,6 +1422,135 @@ async def test_temperature_control(hass: HomeAssistant) -> None: assert err.value.code == const.ERR_NOT_SUPPORTED +@pytest.mark.parametrize( + ("unit_in", "unit_out", "temp_in", "temp_out", "current_in", "current_out"), + [ + (UnitOfTemperature.CELSIUS, "C", "120", 120, "130", 130), + (UnitOfTemperature.FAHRENHEIT, "F", "248", 120, "266", 130), + ], +) +async def test_temperature_control_water_heater( + hass: HomeAssistant, + unit_in: UnitOfTemperature, + unit_out: str, + temp_in: str, + temp_out: float, + current_in: str, + current_out: float, +) -> None: + """Test TemperatureControl trait support for water heater domain.""" + hass.config.units.temperature_unit = unit_in + + min_temp = TemperatureConverter.convert( + water_heater.DEFAULT_MIN_TEMP, + UnitOfTemperature.CELSIUS, + unit_in, + ) + max_temp = TemperatureConverter.convert( + water_heater.DEFAULT_MAX_TEMP, + UnitOfTemperature.CELSIUS, + unit_in, + ) + + trt = trait.TemperatureControlTrait( + hass, + State( + "water_heater.bla", + "attributes", + { + "min_temp": min_temp, + "max_temp": max_temp, + "temperature": temp_in, + "current_temperature": current_in, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "temperatureUnitForUX": unit_out, + "temperatureRange": { + "maxThresholdCelsius": water_heater.DEFAULT_MAX_TEMP, + "minThresholdCelsius": water_heater.DEFAULT_MIN_TEMP, + }, + } + assert trt.query_attributes() == { + "temperatureSetpointCelsius": temp_out, + "temperatureAmbientCelsius": current_out, + } + + +@pytest.mark.parametrize( + ("unit", "temp_init", "temp_in", "temp_out", "current_init"), + [ + (UnitOfTemperature.CELSIUS, "180", 220, 220, "180"), + (UnitOfTemperature.FAHRENHEIT, "356", 220, 428, "356"), + ], +) +async def test_temperature_control_water_heater_set_temperature( + hass: HomeAssistant, + unit: UnitOfTemperature, + temp_init: str, + temp_in: float, + temp_out: float, + current_init: str, +) -> None: + """Test TemperatureControl trait support for water heater domain - SetTemperature.""" + hass.config.units.temperature_unit = unit + + min_temp = TemperatureConverter.convert( + 40, + UnitOfTemperature.CELSIUS, + unit, + ) + max_temp = TemperatureConverter.convert( + 230, + UnitOfTemperature.CELSIUS, + unit, + ) + + trt = trait.TemperatureControlTrait( + hass, + State( + "water_heater.bla", + "attributes", + { + "min_temp": min_temp, + "max_temp": max_temp, + "temperature": temp_init, + "current_temperature": current_init, + }, + ), + BASIC_CONFIG, + ) + + assert trt.can_execute(trait.COMMAND_SET_TEMPERATURE, {}) + + calls = async_mock_service( + hass, water_heater.DOMAIN, water_heater.SERVICE_SET_TEMPERATURE + ) + + with pytest.raises(helpers.SmartHomeError): + await trt.execute( + trait.COMMAND_SET_TEMPERATURE, + BASIC_DATA, + {"temperature": -100}, + {}, + ) + + await trt.execute( + trait.COMMAND_SET_TEMPERATURE, + BASIC_DATA, + {"temperature": temp_in}, + {}, + ) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: "water_heater.bla", + ATTR_TEMPERATURE: temp_out, + } + + async def test_humidity_setting_humidifier_setpoint(hass: HomeAssistant) -> None: """Test HumiditySetting trait support for humidifier domain - setpoint.""" assert helpers.get_google_type(humidifier.DOMAIN, None) is not None @@ -2411,6 +2716,84 @@ async def test_modes_humidifier(hass: HomeAssistant) -> None: } +async def test_modes_water_heater(hass: HomeAssistant) -> None: + """Test Humidifier Mode trait.""" + assert helpers.get_google_type(water_heater.DOMAIN, None) is not None + assert trait.ModesTrait.supported( + water_heater.DOMAIN, WaterHeaterEntityFeature.OPERATION_MODE, None, None + ) + + trt = trait.ModesTrait( + hass, + State( + "water_heater.water_heater", + STATE_OFF, + attributes={ + water_heater.ATTR_OPERATION_LIST: [ + water_heater.STATE_ECO, + water_heater.STATE_HEAT_PUMP, + water_heater.STATE_GAS, + ], + ATTR_SUPPORTED_FEATURES: WaterHeaterEntityFeature.OPERATION_MODE, + water_heater.ATTR_OPERATION_MODE: water_heater.STATE_HEAT_PUMP, + }, + ), + BASIC_CONFIG, + ) + + attribs = trt.sync_attributes() + assert attribs == { + "availableModes": [ + { + "name": "operation mode", + "name_values": [{"name_synonym": ["operation mode"], "lang": "en"}], + "settings": [ + { + "setting_name": "eco", + "setting_values": [{"setting_synonym": ["eco"], "lang": "en"}], + }, + { + "setting_name": "heat_pump", + "setting_values": [ + {"setting_synonym": ["heat_pump"], "lang": "en"} + ], + }, + { + "setting_name": "gas", + "setting_values": [{"setting_synonym": ["gas"], "lang": "en"}], + }, + ], + "ordered": False, + }, + ] + } + + assert trt.query_attributes() == { + "currentModeSettings": {"operation mode": "heat_pump"}, + "on": False, + } + + assert trt.can_execute( + trait.COMMAND_MODES, params={"updateModeSettings": {"operation mode": "gas"}} + ) + + calls = async_mock_service( + hass, water_heater.DOMAIN, water_heater.SERVICE_SET_OPERATION_MODE + ) + await trt.execute( + trait.COMMAND_MODES, + BASIC_DATA, + {"updateModeSettings": {"operation mode": "gas"}}, + {}, + ) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "water_heater.water_heater", + "operation_mode": "gas", + } + + async def test_sound_modes(hass: HomeAssistant) -> None: """Test Mode trait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None @@ -2583,21 +2966,59 @@ async def test_traits_unknown_domains( caplog.clear() -async def test_openclose_cover(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.OpenCloseTrait.supported( - cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None - ) +@pytest.mark.parametrize( + ( + "domain", + "set_position_service", + "close_service", + "open_service", + "set_position_feature", + "attr_position", + "attr_current_position", + ), + [ + ( + cover.DOMAIN, + cover.SERVICE_SET_COVER_POSITION, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_OPEN_COVER, + CoverEntityFeature.SET_POSITION, + cover.ATTR_POSITION, + cover.ATTR_CURRENT_POSITION, + ), + ( + valve.DOMAIN, + valve.SERVICE_SET_VALVE_POSITION, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_OPEN_VALVE, + ValveEntityFeature.SET_POSITION, + valve.ATTR_POSITION, + valve.ATTR_CURRENT_POSITION, + ), + ], +) +async def test_openclose_cover_valve( + hass: HomeAssistant, + domain: str, + set_position_service: str, + close_service: str, + open_service: str, + set_position_feature: int, + attr_position: str, + attr_current_position: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.OpenCloseTrait.supported(domain, set_position_service, None, None) trt = trait.OpenCloseTrait( hass, State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + "open", { - cover.ATTR_CURRENT_POSITION: 75, - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + attr_current_position: 75, + ATTR_SUPPORTED_FEATURES: set_position_feature, }, ), BASIC_CONFIG, @@ -2606,34 +3027,74 @@ async def test_openclose_cover(hass: HomeAssistant) -> None: assert trt.sync_attributes() == {} assert trt.query_attributes() == {"openPercent": 75} - calls_set = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) - calls_open = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + calls_set = async_mock_service(hass, domain, set_position_service) + calls_open = async_mock_service(hass, domain, open_service) + calls_close = async_mock_service(hass, domain, close_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 50}, {}) await trt.execute( trait.COMMAND_OPENCLOSE_RELATIVE, BASIC_DATA, {"openRelativePercent": 50}, {} ) assert len(calls_set) == 1 - assert calls_set[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 50} + assert calls_set[0].data == { + ATTR_ENTITY_ID: f"{domain}.bla", + attr_position: 50, + } + calls_set.pop(0) assert len(calls_open) == 1 - assert calls_open[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls_open[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + calls_open.pop(0) + + assert len(calls_close) == 0 + + await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 0}, {}) + await trt.execute( + trait.COMMAND_OPENCLOSE_RELATIVE, BASIC_DATA, {"openRelativePercent": 0}, {} + ) + assert len(calls_set) == 1 + assert len(calls_close) == 1 + assert calls_close[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + assert len(calls_open) == 0 -async def test_openclose_cover_unknown_state(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain with unknown state.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None +@pytest.mark.parametrize( + ("domain", "open_service", "set_position_feature", "open_feature"), + [ + ( + cover.DOMAIN, + cover.SERVICE_OPEN_COVER, + CoverEntityFeature.SET_POSITION, + CoverEntityFeature.OPEN, + ), + ( + valve.DOMAIN, + valve.SERVICE_OPEN_VALVE, + ValveEntityFeature.SET_POSITION, + ValveEntityFeature.OPEN, + ), + ], +) +async def test_openclose_cover_valve_unknown_state( + hass: HomeAssistant, + open_service: str, + domain: str, + set_position_feature: int, + open_feature: int, +) -> None: + """Test OpenClose trait support with unknown state.""" + assert helpers.get_google_type(domain, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None + cover.DOMAIN, set_position_feature, None, None ) # No state trt = trait.OpenCloseTrait( hass, State( - "cover.bla", + f"{domain}.bla", STATE_UNKNOWN, - {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN}, + {ATTR_SUPPORTED_FEATURES: open_feature}, ), BASIC_CONFIG, ) @@ -2643,30 +3104,51 @@ async def test_openclose_cover_unknown_state(hass: HomeAssistant) -> None: with pytest.raises(helpers.SmartHomeError): trt.query_attributes() - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + calls = async_mock_service(hass, domain, open_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 100}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} with pytest.raises(helpers.SmartHomeError): trt.query_attributes() -async def test_openclose_cover_assumed_state(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.OpenCloseTrait.supported( - cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None - ) +@pytest.mark.parametrize( + ("domain", "set_position_service", "set_position_feature", "state_open"), + [ + ( + cover.DOMAIN, + cover.SERVICE_SET_COVER_POSITION, + CoverEntityFeature.SET_POSITION, + cover.STATE_OPEN, + ), + ( + valve.DOMAIN, + valve.SERVICE_SET_VALVE_POSITION, + ValveEntityFeature.SET_POSITION, + valve.STATE_OPEN, + ), + ], +) +async def test_openclose_cover_valve_assumed_state( + hass: HomeAssistant, + domain: str, + set_position_service: str, + set_position_feature: int, + state_open: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.OpenCloseTrait.supported(domain, set_position_feature, None, None) trt = trait.OpenCloseTrait( hass, State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + state_open, { ATTR_ASSUMED_STATE: True, - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + ATTR_SUPPORTED_FEATURES: set_position_feature, }, ), BASIC_CONFIG, @@ -2676,20 +3158,37 @@ async def test_openclose_cover_assumed_state(hass: HomeAssistant) -> None: assert trt.query_attributes() == {} - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + calls = async_mock_service(hass, domain, set_position_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 40}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 40} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla", cover.ATTR_POSITION: 40} -async def test_openclose_cover_query_only(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.OpenCloseTrait.supported(cover.DOMAIN, 0, None, None) +@pytest.mark.parametrize( + ("domain", "state_open"), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + ), + ], +) +async def test_openclose_cover_valve_query_only( + hass: HomeAssistant, + domain: str, + state_open: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.OpenCloseTrait.supported(domain, 0, None, None) state = State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + state_open, ) trt = trait.OpenCloseTrait( @@ -2705,21 +3204,57 @@ async def test_openclose_cover_query_only(hass: HomeAssistant) -> None: assert trt.query_attributes() == {"openPercent": 100} -async def test_openclose_cover_no_position(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "supported_features", + "open_service", + "close_service", + ), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + cover.STATE_CLOSED, + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + valve.STATE_CLOSED, + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + ), + ], +) +async def test_openclose_cover_valve_no_position( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + supported_features: int, + open_service: str, + close_service: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + domain, + supported_features, None, None, ) state = State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + state_open, { - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + ATTR_SUPPORTED_FEATURES: supported_features, }, ) @@ -2732,20 +3267,20 @@ async def test_openclose_cover_no_position(hass: HomeAssistant) -> None: assert trt.sync_attributes() == {"discreteOnlyOpenClose": True} assert trt.query_attributes() == {"openPercent": 100} - state.state = cover.STATE_CLOSED + state.state = state_closed assert trt.sync_attributes() == {"discreteOnlyOpenClose": True} assert trt.query_attributes() == {"openPercent": 0} - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER) + calls = async_mock_service(hass, domain, close_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 0}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + calls = async_mock_service(hass, domain, open_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 100}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} with pytest.raises( SmartHomeError, match=r"Current position not know for relative command" @@ -3150,7 +3685,9 @@ async def test_humidity_setting_sensor_data( assert err.value.code == const.ERR_NOT_SUPPORTED -async def test_transport_control(hass: HomeAssistant) -> None: +async def test_transport_control( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the TransportControlTrait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None @@ -3159,7 +3696,7 @@ async def test_transport_control(hass: HomeAssistant) -> None: media_player.DOMAIN, feature, None, None ) - now = datetime(2020, 1, 1) + now = datetime(2020, 1, 1, tzinfo=dt_util.UTC) trt = trait.TransportControlTrait( hass, @@ -3190,13 +3727,13 @@ async def test_transport_control(hass: HomeAssistant) -> None: ) # Patch to avoid time ticking over during the command failing the test - with patch("homeassistant.util.dt.utcnow", return_value=now): - await trt.execute( - trait.COMMAND_MEDIA_SEEK_RELATIVE, - BASIC_DATA, - {"relativePositionMs": 10000}, - {}, - ) + freezer.move_to(now) + await trt.execute( + trait.COMMAND_MEDIA_SEEK_RELATIVE, + BASIC_DATA, + {"relativePositionMs": 10000}, + {}, + ) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: "media_player.bla", diff --git a/tests/components/google_assistant_sdk/test_notify.py b/tests/components/google_assistant_sdk/test_notify.py index f35d19e3805bd5..cf3f90097ce0c1 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_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 98b59b7697b740..af8dec6a1823b2 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -1,12 +1,30 @@ # 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 - '{"title": "Soda", "status": "needsAction"}' +# name: test_create_todo_list_item[description].1 + '{"title": "Soda", "status": "needsAction", "due": null, "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", "notes": null}' +# --- +# 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", "due": null, "notes": null}' # --- # name: test_delete_todo_list_item[_handler] tuple( @@ -14,23 +32,142 @@ 'POST', ) # --- -# name: test_partial_update_status[api_responses0] +# name: test_move_todo_item[api_responses0] + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Water', + 'uid': 'some-task-id-1', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Milk', + 'uid': 'some-task-id-2', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Cheese', + 'uid': 'some-task-id-3', + }), + ]) +# --- +# name: test_move_todo_item[api_responses0].1 + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id-3/move?previous=some-task-id-1&alt=json', + 'POST', + ) +# --- +# name: test_move_todo_item[api_responses0].2 + None +# --- +# name: test_move_todo_item[api_responses0].3 + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Water', + 'uid': 'some-task-id-1', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Cheese', + 'uid': 'some-task-id-3', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Milk', + 'uid': 'some-task-id-2', + }), + ]) +# --- +# name: test_move_todo_item[api_responses0].4 + None +# --- +# 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[clear_description] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', 'PATCH', ) # --- -# name: test_partial_update_status[api_responses0].1 - '{"status": "needsAction"}' +# name: test_partial_update[clear_description].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' # --- -# name: test_partial_update_title[api_responses0] +# name: test_partial_update[clear_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_title[api_responses0].1 - '{"title": "Soda"}' +# name: test_partial_update[clear_due_date].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' +# --- +# 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 + '{"title": "Water", "status": "needsAction", "due": null, "notes": "At least one gallon"}' +# --- +# 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 + '{"title": "Water", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' +# --- +# name: test_partial_update[empty_description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[empty_description].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": ""}' +# --- +# 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", "status": "needsAction", "due": null, "notes": null}' +# --- +# 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', + 'PATCH', + ) +# --- +# name: test_partial_update_status[api_responses0].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' # --- # name: test_update_todo_list_item[api_responses0] tuple( @@ -39,5 +176,5 @@ ) # --- # name: test_update_todo_list_item[api_responses0].1 - '{"title": "Soda", "status": "completed"}' + '{"title": "Soda", "status": "completed", "due": null, "notes": null}' # --- diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 7b11372f1d430a..ee1b1e4cfd441e 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -19,13 +19,12 @@ 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 = { @@ -45,17 +44,75 @@ LIST_TASKS_RESPONSE_WATER = { "items": [ - {"id": "some-task-id", "title": "Water", "status": "needsAction"}, + { + "id": "some-task-id", + "title": "Water", + "status": "needsAction", + "description": "Any size is ok", + "position": "00000000000000000001", + }, ], } LIST_TASKS_RESPONSE_MULTIPLE = { "items": [ - {"id": "some-task-id-1", "title": "Water", "status": "needsAction"}, - {"id": "some-task-id-2", "title": "Milk", "status": "needsAction"}, - {"id": "some-task-id-3", "title": "Cheese", "status": "needsAction"}, + { + "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", + }, + ], +} +LIST_TASKS_RESPONSE_REORDER = { + "items": [ + { + "id": "some-task-id-2", + "title": "Milk", + "status": "needsAction", + "position": "00000000000000000002", + }, + { + "id": "some-task-id-1", + "title": "Water", + "status": "needsAction", + "position": "00000000000000000001", + }, + # Task 3 moved after task 1 + { + "id": "some-task-id-3", + "title": "Cheese", + "status": "needsAction", + "position": "000000000000000000011", + }, ], } +# 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 def platforms() -> list[str]: @@ -63,39 +120,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", []) @@ -199,8 +239,20 @@ def mock_http_response(response_handler: list | Callable) -> 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", + }, ], }, ] @@ -225,11 +277,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", }, ] @@ -320,21 +374,20 @@ async def test_task_items_error_response( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, - EMPTY_RESPONSE, # create - LIST_TASKS_RESPONSE, # refresh after delete - ] + (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.""" @@ -348,7 +401,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, ) @@ -394,17 +447,7 @@ async def test_create_todo_list_item_error( ) -@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_update_todo_list_item( hass: HomeAssistant, setup_credentials: None, @@ -470,21 +513,30 @@ async def test_update_todo_list_item_error( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] + (UPDATE_API_RESPONSES, {"rename": "Soda"}), + (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (UPDATE_API_RESPONSES, {"due_date": None}), + (UPDATE_API_RESPONSES, {"description": "At least one gallon"}), + (UPDATE_API_RESPONSES, {"description": ""}), + (UPDATE_API_RESPONSES, {"description": None}), ], + ids=( + "rename", + "due_date", + "clear_due_date", + "description", + "empty_description", + "clear_description", + ), ) -async def test_partial_update_title( +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.""" @@ -498,7 +550,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, ) @@ -509,17 +561,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, @@ -558,7 +600,7 @@ async def test_partial_update_status( LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE_MULTIPLE, [EMPTY_RESPONSE, EMPTY_RESPONSE, EMPTY_RESPONSE], # Delete batch - LIST_TASKS_RESPONSE, # refresh after create + LIST_TASKS_RESPONSE, # refresh after delete ] ) ) @@ -714,3 +756,206 @@ async def test_delete_server_error( 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_MULTIPLE, + EMPTY_RESPONSE, # move + LIST_TASKS_RESPONSE_REORDER, # refresh after move + ] + ], +) +async def test_move_todo_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + hass_ws_client: WebSocketGenerator, + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for re-ordering a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == "3" + + items = await ws_get_items() + assert items == snapshot + + # Move to second in the list + client = await hass_ws_client() + data = { + "id": id, + "type": "todo/item/move", + "entity_id": ENTITY_ID, + "uid": "some-task-id-3", + "previous_uid": "some-task-id-1", + } + await client.send_json_auto_id(data) + resp = await client.receive_json() + assert resp.get("success") + + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == "3" + + 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_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 15132baf25a6a9..9e575389e72d7b 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -8,7 +8,6 @@ CONF_AVOID, CONF_DEPARTURE_TIME, CONF_DESTINATION, - CONF_LANGUAGE, CONF_ORIGIN, CONF_TIME, CONF_TIME_TYPE, @@ -21,7 +20,7 @@ DOMAIN, UNITS_IMPERIAL, ) -from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant from .const import MOCK_CONFIG diff --git a/tests/components/govee_ble/test_sensor.py b/tests/components/govee_ble/test_sensor.py index 185ae2404dada5..5e7ca299fb6174 100644 --- a/tests/components/govee_ble/test_sensor.py +++ b/tests/components/govee_ble/test_sensor.py @@ -1,7 +1,6 @@ """Test the Govee BLE sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -27,6 +26,7 @@ from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -112,9 +112,8 @@ async def test_gvh5178_multi_sensor(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -139,9 +138,8 @@ async def test_gvh5178_multi_sensor(hass: HomeAssistant) -> None: assert primary_temp_sensor.state == "1.0" # Fastforward time without BLE advertisements - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 568b98daec1389..e28582ca2e917d 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -98,7 +98,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.fake_device_1', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -107,7 +107,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'fake-device-1', + 'original_name': None, 'platform': 'gree', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index d2b0a5fbf4e6de..eff96ba1bd3c23 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -4,7 +4,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Panel Light', + 'friendly_name': 'fake-device-1 Panel light', 'icon': 'mdi:lightbulb', }), 'context': , @@ -27,7 +27,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Fresh Air', + 'friendly_name': 'fake-device-1 Fresh air', }), 'context': , 'entity_id': 'switch.fake_device_1_fresh_air', @@ -74,7 +74,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_panel_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -83,11 +83,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:lightbulb', - 'original_name': 'fake-device-1 Panel Light', + 'original_name': 'Panel light', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': 'aabbcc112233_Panel Light', 'unit_of_measurement': None, }), @@ -103,7 +103,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_quiet', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -112,11 +112,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Quiet', + 'original_name': 'Quiet', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'quiet', 'unique_id': 'aabbcc112233_Quiet', 'unit_of_measurement': None, }), @@ -132,7 +132,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_fresh_air', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -141,11 +141,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Fresh Air', + 'original_name': 'Fresh air', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fresh_air', 'unique_id': 'aabbcc112233_Fresh Air', 'unit_of_measurement': None, }), @@ -161,7 +161,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_xfan', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -170,11 +170,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 XFan', + 'original_name': 'XFan', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'xfan', 'unique_id': 'aabbcc112233_XFan', 'unit_of_measurement': None, }), @@ -190,7 +190,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_health_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -199,11 +199,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:pine-tree', - 'original_name': 'fake-device-1 Health mode', + 'original_name': 'Health mode', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'health_mode', 'unique_id': 'aabbcc112233_Health mode', 'unit_of_measurement': None, }), diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index b13544fd3f79c8..f40ab6525d462a 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -1,7 +1,7 @@ """Tests for gree component.""" from datetime import timedelta -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN @@ -24,7 +24,7 @@ def mock_now(): async def test_discovery_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices don't change after multiple discoveries.""" mock_device_1 = build_device_mock( @@ -58,8 +58,8 @@ async def test_discovery_after_setup( device.side_effect = [mock_device_1, mock_device_2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 82ad75b5d2833a..5b261fa266bc78 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from greeclimate.device import HorizontalSwing, VerticalSwing from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError import pytest @@ -49,6 +50,7 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -115,7 +117,7 @@ async def test_discovery_setup_connection_error( async def test_discovery_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices don't change after multiple discoveries.""" MockDevice1 = build_device_mock( @@ -142,8 +144,8 @@ async def test_discovery_after_setup( device.side_effect = [MockDevice1, MockDevice2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 @@ -151,7 +153,7 @@ async def test_discovery_after_setup( async def test_discovery_add_device_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices can be added after initial setup.""" MockDevice1 = build_device_mock( @@ -178,8 +180,8 @@ async def test_discovery_add_device_after_setup( device.side_effect = [MockDevice2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 @@ -187,7 +189,7 @@ async def test_discovery_add_device_after_setup( async def test_discovery_device_bind_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices can be added after a late device bind.""" MockDevice1 = build_device_mock( @@ -212,15 +214,17 @@ async def test_discovery_device_bind_after_setup( MockDevice1.update_state.side_effect = None next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state != STATE_UNAVAILABLE -async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) -> None: +async def test_update_connection_failure( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, device, mock_now +) -> None: """Testing update hvac connection failure exception.""" device().update_state.side_effect = [ DEFAULT_MOCK, @@ -231,8 +235,8 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) await async_setup_gree(hass) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() # First update to make the device available @@ -241,13 +245,13 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) assert state.state != STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() next_update = mock_now + timedelta(minutes=15) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() # Then two more update failures to make the device unavailable @@ -257,7 +261,7 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) async def test_update_connection_failure_recovery( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Testing update hvac connection failure recovery.""" device().update_state.side_effect = [ @@ -270,8 +274,8 @@ async def test_update_connection_failure_recovery( # First update becomes unavailable next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -280,8 +284,8 @@ async def test_update_connection_failure_recovery( # Second update restores the connection next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -290,7 +294,7 @@ async def test_update_connection_failure_recovery( async def test_update_unhandled_exception( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Testing update hvac connection unhandled response exception.""" device().update_state.side_effect = [DEFAULT_MOCK, Exception] @@ -302,8 +306,8 @@ async def test_update_unhandled_exception( assert state.state != STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -312,15 +316,15 @@ async def test_update_unhandled_exception( async def test_send_command_device_timeout( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test for sending power on command to the device with a device timeout.""" await async_setup_gree(hass) # First update to make the device available next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -535,7 +539,7 @@ async def test_send_invalid_preset_mode( """Test for sending preset mode command to the device.""" await async_setup_gree(hass) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SET_PRESET_MODE, @@ -696,7 +700,7 @@ async def test_send_invalid_fan_mode( """Test for sending fan mode command to the device.""" await async_setup_gree(hass) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, @@ -777,7 +781,7 @@ async def test_send_invalid_swing_mode( """Test for sending swing mode command to the device.""" await async_setup_gree(hass) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_MODE, diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 3189e344c62b44..7b83ed9eb0d7a8 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -699,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/guardian/conftest.py b/tests/components/guardian/conftest.py index acf59aeea86932..f2cde0a553d230 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/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index b58b2ccdba3aa1..ec288461661682 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -23,6 +23,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": "guardian", "title": REDACTED, "data": { diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py index 58cbd3eac560ef..f843ab4decad79 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.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, + issue_registry: ir.IssueRegistry, +) -> 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" + + 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 22051808cccf2a..0cce33f6dfd9ca 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 5c4717fd561c48..0923967a480035 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -12,12 +12,7 @@ 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 @@ 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_repairs.py b/tests/components/hassio/test_repairs.py index 21bf7e5b47a7a4..5dd73a2161538e 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -100,6 +100,7 @@ async def test_supervisor_issue_repair_flow( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -195,6 +196,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -309,6 +311,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -389,6 +392,7 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -488,6 +492,7 @@ async def test_mount_failed_repair_flow( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -599,6 +604,7 @@ async def test_supervisor_issue_docker_config_repair_flow( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 1784ba83446183..70d96a0d5cbe8a 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -249,6 +249,8 @@ async def set_signal(): 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 bb4b5b275d2d13..c421a1b8c5ce4e 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -1664,7 +1664,9 @@ def _fake_states( 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/holiday/__init__.py b/tests/components/holiday/__init__.py new file mode 100644 index 00000000000000..e906586aabc951 --- /dev/null +++ b/tests/components/holiday/__init__.py @@ -0,0 +1 @@ +"""Tests for the Holiday integration.""" diff --git a/tests/components/komfovent/conftest.py b/tests/components/holiday/conftest.py similarity index 68% rename from tests/components/komfovent/conftest.py rename to tests/components/holiday/conftest.py index d9cb0950c74f12..d9b0d1a5788af0 100644 --- a/tests/components/komfovent/conftest.py +++ b/tests/components/holiday/conftest.py @@ -1,4 +1,4 @@ -"""Common fixtures for the Komfovent tests.""" +"""Common fixtures for the Holiday tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, patch @@ -9,6 +9,6 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" with patch( - "homeassistant.components.komfovent.async_setup_entry", return_value=True + "homeassistant.components.holiday.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py new file mode 100644 index 00000000000000..06011fb8e6baa5 --- /dev/null +++ b/tests/components/holiday/test_calendar.py @@ -0,0 +1,229 @@ +"""Tests for calendar platform of Holiday integration.""" +from datetime import datetime, timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, +) +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_holiday_calendar_entity( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test HolidayCalendarEntity functionality.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "US", CONF_PROVINCE: "AK"}, + title="United States, AK", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await async_setup_component(hass, "calendar", {}) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + state = hass.states.get("calendar.united_states_ak") + assert state is not None + assert state.state == "on" + + # Test holidays for the next year + freezer.move_to(datetime(2023, 12, 31, 12, tzinfo=dt_util.UTC)) + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now() + timedelta(days=1), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2024-01-01", + "end": "2024-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + +async def test_default_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default language.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "FR", CONF_PROVINCE: "BL"}, + title="France, BL", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test French calendar with English language + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "France, BL", + } + ] + } + } + + # Test French calendar with French language + hass.config.language = "fr" + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "Jour de l'an", + "location": "France, BL", + } + ] + } + } + + +async def test_no_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test language defaults to English if language not exist.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "AL"}, + title="Albania", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.albania", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.albania": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "Albania", + } + ] + } + } + + +async def test_no_next_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if there is no next event.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "DE"}, + title="Germany", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Move time to out of reach + freezer.move_to(datetime(dt_util.now().year + 5, 1, 1, 12, tzinfo=dt_util.UTC)) + async_fire_time_changed(hass) + + state = hass.states.get("calendar.germany") + assert state is not None + assert state.state == "off" + assert state.attributes == {"friendly_name": "Germany"} diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py new file mode 100644 index 00000000000000..7dce6131616200 --- /dev/null +++ b/tests/components/holiday/test_config_flow.py @@ -0,0 +1,220 @@ +"""Test the Holiday config flow.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_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 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Germany, BW" + assert result3["data"] == { + "country": "DE", + "province": "BW", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_no_subdivision(hass: HomeAssistant) -> None: + """Test we get the forms correctly without subdivision.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Sweden" + assert result2["data"] == { + "country": "SE", + } + + +async def test_form_translated_title(hass: HomeAssistant) -> None: + """Test the title gets translated.""" + hass.config.language = "de" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "Schweden" + + +async def test_single_combination_country_province(hass: HomeAssistant) -> None: + """Test that configuring more than one instance is rejected.""" + data_de = { + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BW", + } + data_se = { + CONF_COUNTRY: "SE", + } + MockConfigEntry(domain=DOMAIN, data=data_de).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=data_se).add_to_hass(hass) + + # Test for country without subdivisions + result_se = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_se, + ) + assert result_se["type"] == FlowResultType.ABORT + assert result_se["reason"] == "already_configured" + + # Test for country with subdivisions + result_de_step1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_de, + ) + assert result_de_step1["type"] == FlowResultType.FORM + + result_de_step2 = await hass.config_entries.flow.async_configure( + result_de_step1["flow_id"], + { + CONF_PROVINCE: data_de[CONF_PROVINCE], + }, + ) + assert result_de_step2["type"] == FlowResultType.ABORT + assert result_de_step2["reason"] == "already_configured" + + +async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: + """Test the config flow if using not babel supported language.""" + hass.config.language = "en-XX" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result["title"] == "Sweden" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Germany, BW" + assert result["data"] == { + "country": "DE", + "province": "BW", + } + + +async def test_form_babel_replace_dash_with_underscore(hass: HomeAssistant) -> None: + """Test the config flow if using language with dash.""" + hass.config.language = "en-GB" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result["title"] == "Sweden" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Germany, BW" + assert result["data"] == { + "country": "DE", + "province": "BW", + } diff --git a/tests/components/holiday/test_init.py b/tests/components/holiday/test_init.py new file mode 100644 index 00000000000000..a044e390a68d46 --- /dev/null +++ b/tests/components/holiday/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Holiday integration.""" + +from homeassistant.components.holiday.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG_DATA = { + "country": "Germany", + "province": "BW", +} + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test removing integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + state: ConfigEntryState = entry.state + assert state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py index 085ed4f06410e8..d754c67ad49ed0 100644 --- a/tests/components/homeassistant/test_scene.py +++ b/tests/components/homeassistant/test_scene.py @@ -8,6 +8,7 @@ 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 b5bd748a5dc6c7..92c8aac3eba461 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 9870beedafc5f6..a8f001ff5e0d3e 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/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index b4554f1a4e6ad8..513827b5432026 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -38,34 +39,33 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at.""" now = dt_util.now() trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "5:00:00"}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}", - "id": "{{ trigger.id}}", - }, + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "5:00:00"}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}", + "id": "{{ trigger.id}}", }, - } - }, - ) - await hass.async_block_till_done() + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -79,7 +79,7 @@ async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: ("has_date", "has_time"), [(True, True), (True, False), (False, True)] ) async def test_if_fires_using_at_input_datetime( - hass: HomeAssistant, calls, has_date, has_time + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, has_date, has_time ) -> None: """Test for firing at input_datetime.""" await async_setup_component( @@ -107,24 +107,22 @@ async def test_if_fires_using_at_input_datetime( time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "input_datetime.trigger"}, - "action": { - "service": "test.automation", - "data_template": {"some": some_data}, - }, - } - }, - ) - await hass.async_block_till_done() + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "input_datetime.trigger"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -161,7 +159,9 @@ async def test_if_fires_using_at_input_datetime( ) -async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_multiple_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at.""" now = dt_util.now() @@ -169,26 +169,23 @@ async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" - }, + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" }, - } - }, - ) - await hass.async_block_till_done() + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -203,7 +200,9 @@ async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: assert calls[1].data["some"] == "time - 6" -async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_using_wrong_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """YAML translates time values to total seconds. This should break the before rule. @@ -214,25 +213,23 @@ async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: year=now.year + 1, hour=1, minute=0, second=0 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - with assert_setup_component(1, automation.DOMAIN): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time", - "at": 3605, - # Total seconds. Hour = 3600 second - }, - "action": {"service": "test.automation"}, - } - }, - ) - await hass.async_block_till_done() + freezer.move_to(time_that_will_not_match_right_away) + with assert_setup_component(1, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": 3605, + # Total seconds. Hour = 3600 second + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE async_fire_time_changed( @@ -409,7 +406,9 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None: assert len(mock_track_time_change.mock_calls) == 3 -async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_at_sensor( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -424,24 +423,22 @@ async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "sensor.next_alarm"}, - "action": { - "service": "test.automation", - "data_template": {"some": some_data}, - }, - } - }, - ) - await hass.async_block_till_done() + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "sensor.next_alarm"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index e7a6a98bb96dc7..0f6a075eb6e4c5 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -1,7 +1,7 @@ """The tests for the time_pattern automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -27,33 +27,33 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_hour_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if hour is matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=3 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 0, - "minutes": "*", - "seconds": "*", - }, - "action": { - "service": "test.automation", - "data_template": {"id": "{{ trigger.id}}"}, - }, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 0, + "minutes": "*", + "seconds": "*", + }, + "action": { + "service": "test.automation", + "data_template": {"id": "{{ trigger.id}}"}, + }, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, hour=0)) await hass.async_block_till_done() @@ -72,30 +72,30 @@ async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: assert calls[0].data["id"] == 0 -async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_minute_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if minutes are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=30 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": 0, - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": 0, + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, minute=0)) @@ -103,30 +103,30 @@ async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_second_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=30 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": 0, - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": 0, + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, second=0)) @@ -135,31 +135,29 @@ async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: async def test_if_fires_when_second_as_string_matches( - hass: HomeAssistant, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls ) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=15 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "30", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "30", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, time_that_will_not_match_right_away + timedelta(seconds=15) @@ -169,30 +167,30 @@ async def test_if_fires_when_second_as_string_matches( assert len(calls) == 1 -async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_all_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if everything matches.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=4 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 1, - "minutes": 2, - "seconds": 3, - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 1, + "minutes": 2, + "seconds": 3, + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=1, minute=2, second=3) @@ -202,30 +200,30 @@ async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_seconds( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every second.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "/10", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "/10", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=0, minute=0, second=10) @@ -235,31 +233,31 @@ async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: assert len(calls) >= 1 -async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_minutes( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every minute.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "/2", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "/2", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=0, minute=2, second=0) @@ -269,30 +267,30 @@ async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_hours( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "/2", - "minutes": "*", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "/2", + "minutes": "*", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=2, minute=0, second=0) @@ -302,25 +300,25 @@ async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_default_values(hass: HomeAssistant, calls) -> None: +async def test_default_values( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at 2 minutes every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time_pattern", "minutes": "2"}, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time_pattern", "minutes": "2"}, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=1, minute=2, second=0) diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index fbc77cdee9ee9e..f58d561bfb3312 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 4d43d29463a76f..65636b27a16cc7 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 e00603dc8f784f..11961c09a2de38 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 58d47c41987492..242b316de6694a 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 addc519c86533c..f8cdcd8a13bdf7 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 fe151c902cb24c..8c6d4328065f1f 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 447cdc99a57eac..64c5cd9cc7421b 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 158efa477d491e..1d42325d54c697 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 b88412896118af..a44db05a37b439 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 df54cce1b3f804..118e67a43b1c96 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 6fae8337aae5e6..7568e7a48445c1 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 104b9dd61ce386..1954d6bf8cab88 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 d2f0d87c507f8e..23e53eef94da28 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 1c3fb0914f314f..5bfbe0b1627ae2 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/conftest.py b/tests/components/homekit_controller/conftest.py index 043213ec159bb4..904b752205efd3 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -1,9 +1,9 @@ """HomeKit controller session fixtures.""" import datetime -from unittest import mock import unittest.mock from aiohomekit.testing import FakeController +from freezegun import freeze_time import pytest import homeassistant.util.dt as dt_util @@ -13,14 +13,13 @@ pytest.register_assert_rewrite("tests.components.homekit_controller.common") -@pytest.fixture -def utcnow(request): +@pytest.fixture(autouse=True) +def freeze_time_in_future(request): """Freeze time at a known point.""" now = dt_util.utcnow() start_dt = datetime.datetime(now.year + 1, 1, 1, 0, 0, 0, tzinfo=now.tzinfo) - with mock.patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - yield dt_utcnow + with freeze_time(start_dt) as frozen_time: + yield frozen_time @pytest.fixture diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_cover.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_cover.json new file mode 100644 index 00000000000000..cfb94b104b0577 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_cover.json @@ -0,0 +1,323 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 878448248, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "RYSE Inc." + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "RYSE Shade" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Kitchen Window" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "cover.kitchen_window" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "3.6.2" + }, + { + "iid": 8, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "1.0.0" + } + ] + }, + { + "iid": 9, + "type": "96", + "characteristics": [ + { + "iid": 10, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 11, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 12, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 13, + "type": "8C", + "characteristics": [ + { + "iid": 14, + "type": "6D", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 15, + "type": "7C", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 16, + "type": "72", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + } + ] + } + ] + }, + { + "aid": 123016423, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 155, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 156, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "RYSE Inc." + }, + { + "iid": 157, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "RYSE Shade" + }, + { + "iid": 158, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Family Room North" + }, + { + "iid": 159, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "cover.family_door_north" + }, + { + "iid": 160, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "3.6.2" + }, + { + "iid": 161, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "1.0.0" + } + ] + }, + { + "iid": 162, + "type": "96", + "characteristics": [ + { + "iid": 163, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 164, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 165, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 166, + "type": "8C", + "characteristics": [ + { + "iid": 167, + "type": "6D", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 98 + }, + { + "iid": 168, + "type": "7C", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 98 + }, + { + "iid": 169, + "type": "72", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_heater_cooler.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_heater_cooler.json new file mode 100644 index 00000000000000..4526179b4da6f0 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_heater_cooler.json @@ -0,0 +1,229 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 1233851541, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 163, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 164, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Lookin" + }, + { + "iid": 165, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Climate Control" + }, + { + "iid": 166, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "89 Living Room" + }, + { + "iid": 167, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "climate.89_living_room" + }, + { + "iid": 168, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ], + "primary": false + }, + { + "iid": 169, + "type": "BC", + "characteristics": [ + { + "iid": 170, + "type": "B2", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 0 + }, + { + "iid": 171, + "type": "B1", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2, 3], + "value": 2 + }, + { + "iid": 172, + "type": "11", + "perms": ["pr", "ev"], + "format": "float", + "unit": "celsius", + "minStep": 0.1, + "maxValue": 1000, + "minValue": -273.1, + "value": 22.8 + }, + { + "iid": 173, + "type": "35", + "perms": ["pr", "pw", "ev"], + "format": "float", + "unit": "celsius", + "minStep": 0.1, + "maxValue": 30.0, + "minValue": 16.0, + "value": 20.0 + }, + { + "iid": 174, + "type": "36", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 1 + }, + { + "iid": 180, + "type": "10", + "perms": ["pr", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 100, + "minValue": 0, + "value": 60 + } + ], + "primary": true, + "linked": [175] + }, + { + "iid": 175, + "type": "B7", + "characteristics": [ + { + "iid": 176, + "type": "B0", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 1 + }, + { + "iid": 177, + "type": "29", + "perms": ["pr", "pw", "ev"], + "format": "float", + "description": "Fan Mode", + "unit": "percentage", + "minStep": 33.333333333333336, + "maxValue": 100, + "minValue": 0, + "value": 33.33333333333334 + }, + { + "iid": 178, + "type": "BF", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "description": "Fan Auto", + "valid-values": [0, 1], + "value": 0 + }, + { + "iid": 179, + "type": "B6", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "description": "Swing Mode", + "valid-values": [0, 1], + "value": 0 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_light.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_light.json new file mode 100644 index 00000000000000..2e5c8719876aa0 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_light.json @@ -0,0 +1,183 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 3982136094, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 597, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 598, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "FirstAlert" + }, + { + "iid": 599, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "1039102" + }, + { + "iid": 600, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Laundry Smoke ED78" + }, + { + "iid": 601, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "light.laundry_smoke_ed78" + }, + { + "iid": 602, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "1.4.84" + }, + { + "iid": 603, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "9.0.0" + } + ] + }, + { + "iid": 604, + "type": "96", + "characteristics": [ + { + "iid": 605, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "value": 100 + }, + { + "iid": 606, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 607, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 608, + "type": "43", + "characteristics": [ + { + "iid": 609, + "type": "25", + "perms": ["pr", "pw", "ev"], + "format": "bool", + "value": false + }, + { + "iid": 610, + "type": "8", + "perms": ["pr", "pw", "ev"], + "format": "int", + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "value": 100 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_cover.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_cover.json new file mode 100644 index 00000000000000..d58de1d2b98dd2 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_cover.json @@ -0,0 +1,330 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 878448248, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "RYSE Inc." + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "RYSE Shade" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Kitchen Window" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "cover.kitchen_window" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "3.6.2" + }, + { + "iid": 8, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "1.0.0" + } + ] + }, + { + "iid": 9, + "type": "96", + "characteristics": [ + { + "iid": 10, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 11, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 12, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 13, + "type": "8C", + "characteristics": [ + { + "iid": 14, + "type": "6D", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 15, + "type": "7C", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 16, + "type": "72", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + } + ] + } + ] + }, + { + "aid": 123016423, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 155, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 156, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "RYSE Inc." + }, + { + "iid": 157, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "RYSE Shade" + }, + { + "iid": 158, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Family Room North" + }, + { + "iid": 159, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "cover.family_door_north" + }, + { + "iid": 160, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "3.6.2" + }, + { + "iid": 161, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "1.0.0" + } + ] + }, + { + "iid": 162, + "type": "96", + "characteristics": [ + { + "iid": 163, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 164, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 165, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 166, + "type": "8C", + "characteristics": [ + { + "iid": 167, + "type": "6D", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 98 + }, + { + "iid": 168, + "type": "7C", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 98 + }, + { + "iid": 169, + "type": "72", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 170, + "type": "6F", + "perms": ["pw", "pr", "ev"], + "format": "bool", + "value": false + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_heater_cooler.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_heater_cooler.json new file mode 100644 index 00000000000000..f96d168fc5f631 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_heater_cooler.json @@ -0,0 +1,237 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 1233851541, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 163, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 164, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Lookin" + }, + { + "iid": 165, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Climate Control" + }, + { + "iid": 166, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "89 Living Room" + }, + { + "iid": 167, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "climate.89_living_room" + }, + { + "iid": 168, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ], + "primary": false + }, + { + "iid": 169, + "type": "BC", + "characteristics": [ + { + "iid": 170, + "type": "B2", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 0 + }, + { + "iid": 171, + "type": "B1", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2, 3], + "value": 2 + }, + { + "iid": 172, + "type": "11", + "perms": ["pr", "ev"], + "format": "float", + "unit": "celsius", + "minStep": 0.1, + "maxValue": 1000, + "minValue": -273.1, + "value": 22.8 + }, + { + "iid": 173, + "type": "35", + "perms": ["pr", "pw", "ev"], + "format": "float", + "unit": "celsius", + "minStep": 0.1, + "maxValue": 30.0, + "minValue": 16.0, + "value": 20.0 + }, + { + "iid": 174, + "type": "36", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 1 + }, + { + "iid": 180, + "type": "10", + "perms": ["pr", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 100, + "minValue": 0, + "value": 60 + }, + { + "iid": 290, + "type": "B6", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 1 + } + ], + "primary": true, + "linked": [175] + }, + { + "iid": 175, + "type": "B7", + "characteristics": [ + { + "iid": 176, + "type": "B0", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 1 + }, + { + "iid": 177, + "type": "29", + "perms": ["pr", "pw", "ev"], + "format": "float", + "description": "Fan Mode", + "unit": "percentage", + "minStep": 33.333333333333336, + "maxValue": 100, + "minValue": 0, + "value": 33.33333333333334 + }, + { + "iid": 178, + "type": "BF", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "description": "Fan Auto", + "valid-values": [0, 1], + "value": 0 + }, + { + "iid": 179, + "type": "B6", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "description": "Swing Mode", + "valid-values": [0, 1], + "value": 0 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier.json new file mode 100644 index 00000000000000..8dd336391903d0 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier.json @@ -0,0 +1,173 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 293334836, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "switchbot" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "WoHumi" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Humidifier 182A" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "humidifier.humidifier_182a" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "BD", + "characteristics": [ + { + "iid": 9, + "type": "10", + "perms": ["pr", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 100, + "minValue": 0, + "value": 0 + }, + { + "iid": 10, + "type": "B3", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 0 + }, + { + "iid": 11, + "type": "B4", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "maxValue": 1, + "minValue": 1, + "valid-values": [1], + "value": 1 + }, + { + "iid": 12, + "type": "B0", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + }, + { + "iid": 13, + "type": "CA", + "perms": ["pr", "pw", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 100, + "minValue": 0, + "value": 45 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier_new_range.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier_new_range.json new file mode 100644 index 00000000000000..28ef6c91d253fd --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier_new_range.json @@ -0,0 +1,173 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 293334836, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "switchbot" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "WoHumi" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Humidifier 182A" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "humidifier.humidifier_182a" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "BD", + "characteristics": [ + { + "iid": 9, + "type": "10", + "perms": ["pr", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 100, + "minValue": 0, + "value": 0 + }, + { + "iid": 10, + "type": "B3", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 0 + }, + { + "iid": 11, + "type": "B4", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "maxValue": 1, + "minValue": 1, + "valid-values": [1], + "value": 1 + }, + { + "iid": 12, + "type": "B0", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + }, + { + "iid": 13, + "type": "CA", + "perms": ["pr", "pw", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 80, + "minValue": 20, + "value": 45 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_light.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_light.json new file mode 100644 index 00000000000000..b5614184fae5a4 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_light.json @@ -0,0 +1,205 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 3982136094, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 597, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 598, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "FirstAlert" + }, + { + "iid": 599, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "1039102" + }, + { + "iid": 600, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Laundry Smoke ED78" + }, + { + "iid": 601, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "light.laundry_smoke_ed78" + }, + { + "iid": 602, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "1.4.84" + }, + { + "iid": 603, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "9.0.0" + } + ] + }, + { + "iid": 604, + "type": "96", + "characteristics": [ + { + "iid": 605, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "value": 100 + }, + { + "iid": 606, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 607, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 608, + "type": "43", + "characteristics": [ + { + "iid": 609, + "type": "25", + "perms": ["pr", "pw", "ev"], + "format": "bool", + "value": false + }, + { + "iid": 610, + "type": "8", + "perms": ["pr", "pw", "ev"], + "format": "int", + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "value": 100 + }, + { + "iid": 611, + "type": "13", + "perms": ["pr", "pw", "ev"], + "format": "float", + "maxValue": 360, + "minStep": 1, + "minValue": 0, + "unit": "arcdegrees", + "value": 0 + }, + { + "iid": 612, + "type": "2F", + "perms": ["pr", "pw", "ev"], + "format": "float", + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "value": 75 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_diagnostics.ambr b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..d3205b09de39f9 --- /dev/null +++ b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr @@ -0,0 +1,635 @@ +# serializer version: 1 +# name: test_config_entry + dict({ + 'config-entry': dict({ + 'data': dict({ + 'AccessoryPairingID': '00:00:00:00:00:00', + }), + 'title': 'test', + 'version': 1, + }), + 'config-num': 0, + 'devices': list([ + dict({ + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': 'diagnostic', + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Identify', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Identify', + }), + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'state': 'unknown', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Light Strip', + 'state': dict({ + 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + 'hs', + ]), + 'supported_features': 0, + 'xy_color': None, + }), + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + ]), + 'hw_version': '', + 'manfacturer': 'Koogeek', + 'model': 'LS1', + 'name': 'Koogeek-LS1-20833F', + 'sw_version': '2.2.15', + }), + ]), + 'entity-map': list([ + dict({ + 'aid': 1, + 'services': list([ + dict({ + 'characteristics': list([ + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 2, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Koogeek-LS1-20833F', + }), + dict({ + 'description': 'Manufacturer', + 'format': 'string', + 'iid': 3, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000020-0000-1000-8000-0026BB765291', + 'value': 'Koogeek', + }), + dict({ + 'description': 'Model', + 'format': 'string', + 'iid': 4, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000021-0000-1000-8000-0026BB765291', + 'value': 'LS1', + }), + dict({ + 'description': 'Serial Number', + 'format': 'string', + 'iid': 5, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000030-0000-1000-8000-0026BB765291', + 'value': '**REDACTED**', + }), + dict({ + 'description': 'Identify', + 'format': 'bool', + 'iid': 6, + 'perms': list([ + 'pw', + ]), + 'type': '00000014-0000-1000-8000-0026BB765291', + }), + dict({ + 'description': 'Firmware Revision', + 'format': 'string', + 'iid': 23, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000052-0000-1000-8000-0026BB765291', + 'value': '2.2.15', + }), + ]), + 'iid': 1, + 'type': '0000003E-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'On', + 'format': 'bool', + 'iid': 8, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000025-0000-1000-8000-0026BB765291', + 'value': False, + }), + dict({ + 'description': 'Hue', + 'format': 'float', + 'iid': 9, + 'maxValue': 359, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000013-0000-1000-8000-0026BB765291', + 'unit': 'arcdegrees', + 'value': 44, + }), + dict({ + 'description': 'Saturation', + 'format': 'float', + 'iid': 10, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '0000002F-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 0, + }), + dict({ + 'description': 'Brightness', + 'format': 'int', + 'iid': 11, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000008-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 100, + }), + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 12, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Light Strip', + }), + ]), + 'iid': 7, + 'type': '00000043-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'TIMER_SETTINGS', + 'format': 'tlv8', + 'iid': 14, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '4AAAF942-0DEC-11E5-B939-0800200C9A66', + 'value': 'AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }), + ]), + 'iid': 13, + 'type': '4AAAF940-0DEC-11E5-B939-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'FW Upgrade supported types', + 'format': 'string', + 'iid': 16, + 'maxLen': 64, + 'perms': list([ + 'pr', + 'hd', + ]), + 'type': '151909D2-3802-11E4-916C-0800200C9A66', + 'value': 'url,data', + }), + dict({ + 'description': 'FW Upgrade URL', + 'format': 'string', + 'iid': 17, + 'maxLen': 64, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D1-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'description': 'FW Upgrade Status', + 'format': 'int', + 'iid': 18, + 'perms': list([ + 'pr', + 'ev', + 'hd', + ]), + 'type': '151909D6-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'FW Upgrade Data', + 'format': 'data', + 'iid': 19, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D7-3802-11E4-916C-0800200C9A66', + }), + ]), + 'iid': 15, + 'type': '151909D0-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'Timezone', + 'format': 'int', + 'iid': 21, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D5-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'Time value since Epoch', + 'format': 'int', + 'iid': 22, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D4-3802-11E4-916C-0800200C9A66', + 'value': 1550348623, + }), + ]), + 'iid': 20, + 'type': '151909D3-3802-11E4-916C-0800200C9A66', + }), + ]), + }), + ]), + }) +# --- +# name: test_device + dict({ + 'config-entry': dict({ + 'data': dict({ + 'AccessoryPairingID': '00:00:00:00:00:00', + }), + 'title': 'test', + 'version': 1, + }), + 'config-num': 0, + 'device': dict({ + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': 'diagnostic', + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Identify', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Identify', + }), + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'state': 'unknown', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Light Strip', + 'state': dict({ + 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + 'hs', + ]), + 'supported_features': 0, + 'xy_color': None, + }), + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + ]), + 'hw_version': '', + 'manfacturer': 'Koogeek', + 'model': 'LS1', + 'name': 'Koogeek-LS1-20833F', + 'sw_version': '2.2.15', + }), + 'entity-map': list([ + dict({ + 'aid': 1, + 'services': list([ + dict({ + 'characteristics': list([ + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 2, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Koogeek-LS1-20833F', + }), + dict({ + 'description': 'Manufacturer', + 'format': 'string', + 'iid': 3, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000020-0000-1000-8000-0026BB765291', + 'value': 'Koogeek', + }), + dict({ + 'description': 'Model', + 'format': 'string', + 'iid': 4, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000021-0000-1000-8000-0026BB765291', + 'value': 'LS1', + }), + dict({ + 'description': 'Serial Number', + 'format': 'string', + 'iid': 5, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000030-0000-1000-8000-0026BB765291', + 'value': '**REDACTED**', + }), + dict({ + 'description': 'Identify', + 'format': 'bool', + 'iid': 6, + 'perms': list([ + 'pw', + ]), + 'type': '00000014-0000-1000-8000-0026BB765291', + }), + dict({ + 'description': 'Firmware Revision', + 'format': 'string', + 'iid': 23, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000052-0000-1000-8000-0026BB765291', + 'value': '2.2.15', + }), + ]), + 'iid': 1, + 'type': '0000003E-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'On', + 'format': 'bool', + 'iid': 8, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000025-0000-1000-8000-0026BB765291', + 'value': False, + }), + dict({ + 'description': 'Hue', + 'format': 'float', + 'iid': 9, + 'maxValue': 359, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000013-0000-1000-8000-0026BB765291', + 'unit': 'arcdegrees', + 'value': 44, + }), + dict({ + 'description': 'Saturation', + 'format': 'float', + 'iid': 10, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '0000002F-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 0, + }), + dict({ + 'description': 'Brightness', + 'format': 'int', + 'iid': 11, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000008-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 100, + }), + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 12, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Light Strip', + }), + ]), + 'iid': 7, + 'type': '00000043-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'TIMER_SETTINGS', + 'format': 'tlv8', + 'iid': 14, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '4AAAF942-0DEC-11E5-B939-0800200C9A66', + 'value': 'AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }), + ]), + 'iid': 13, + 'type': '4AAAF940-0DEC-11E5-B939-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'FW Upgrade supported types', + 'format': 'string', + 'iid': 16, + 'maxLen': 64, + 'perms': list([ + 'pr', + 'hd', + ]), + 'type': '151909D2-3802-11E4-916C-0800200C9A66', + 'value': 'url,data', + }), + dict({ + 'description': 'FW Upgrade URL', + 'format': 'string', + 'iid': 17, + 'maxLen': 64, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D1-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'description': 'FW Upgrade Status', + 'format': 'int', + 'iid': 18, + 'perms': list([ + 'pr', + 'ev', + 'hd', + ]), + 'type': '151909D6-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'FW Upgrade Data', + 'format': 'data', + 'iid': 19, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D7-3802-11E4-916C-0800200C9A66', + }), + ]), + 'iid': 15, + 'type': '151909D0-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'Timezone', + 'format': 'int', + 'iid': 21, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D5-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'Time value since Epoch', + 'format': 'int', + 'iid': 22, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D4-3802-11E4-916C-0800200C9A66', + 'value': 1550348623, + }), + ]), + 'iid': 20, + 'type': '151909D3-3802-11E4-916C-0800200C9A66', + }), + ]), + }), + ]), + }) +# --- diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index a0c6fd00ee6ccb..4b4ffeb9aa38bb 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -6230,7 +6230,7 @@ }), ]) # --- -# name: test_snapshots[home_assistant_bridge_basic_fan] +# name: test_snapshots[home_assistant_bridge_basic_cover] list([ dict({ 'device': dict({ @@ -6243,21 +6243,21 @@ ]), 'disabled_by': None, 'entry_type': None, - 'hw_version': '', + 'hw_version': '1.0.0', 'identifiers': list([ list([ 'homekit_controller:accessory-id', - '00:00:00:00:00:00:aid:766313939', + '00:00:00:00:00:00:aid:123016423', ]), ]), 'is_new': False, - 'manufacturer': 'Home Assistant', - 'model': 'Fan', - 'name': 'Ceiling Fan', + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Family Room North', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '0.104.0.dev0', + 'sw_version': '3.6.2', }), 'entities': list([ dict({ @@ -6271,7 +6271,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.ceiling_fan_identify', + 'entity_id': 'button.family_room_north_identify', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -6280,19 +6280,19 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ceiling Fan Identify', + 'original_name': 'Family Room North Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00:00:00:00:00:00_766313939_1_2', + 'unique_id': '00:00:00:00:00:00_123016423_1_155', 'unit_of_measurement': None, }), 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Ceiling Fan Identify', + 'friendly_name': 'Family Room North Identify', }), - 'entity_id': 'button.ceiling_fan_identify', + 'entity_id': 'button.family_room_north_identify', 'state': 'unknown', }), }), @@ -6301,15 +6301,13 @@ 'aliases': list([ ]), 'area_id': None, - 'capabilities': dict({ - 'preset_modes': None, - }), + 'capabilities': None, 'config_entry_id': 'TestData', 'device_class': None, 'disabled_by': None, - 'domain': 'fan', + 'domain': 'cover', 'entity_category': None, - 'entity_id': 'fan.ceiling_fan', + 'entity_id': 'cover.family_room_north', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -6318,25 +6316,64 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ceiling Fan', + 'original_name': 'Family Room North', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': '00:00:00:00:00:00_766313939_8', + 'unique_id': '00:00:00:00:00:00_123016423_166', 'unit_of_measurement': None, }), 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Ceiling Fan', - 'percentage': 0, - 'percentage_step': 1.0, - 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'current_position': 98, + 'friendly_name': 'Family Room North', + 'supported_features': , }), - 'entity_id': 'fan.ceiling_fan', - 'state': 'off', + 'entity_id': 'cover.family_room_north', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.family_room_north_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Family Room North Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_123016423_162', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Family Room North Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.family_room_north_battery', + 'state': '100', }), }), ]), @@ -6362,11 +6399,11 @@ 'is_new': False, 'manufacturer': 'Home Assistant', 'model': 'Bridge', - 'name': 'Home Assistant Bridge', + 'name': 'HASS Bridge S6', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '0.104.0.dev0', + 'sw_version': '2024.2.0', }), 'entities': list([ dict({ @@ -6380,7 +6417,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.home_assistant_bridge_identify', + 'entity_id': 'button.hass_bridge_s6_identify', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -6389,7 +6426,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Home Assistant Bridge Identify', + 'original_name': 'HASS Bridge S6 Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, 'supported_features': 0, @@ -6399,9 +6436,9 @@ }), 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Home Assistant Bridge Identify', + 'friendly_name': 'HASS Bridge S6 Identify', }), - 'entity_id': 'button.home_assistant_bridge_identify', + 'entity_id': 'button.hass_bridge_s6_identify', 'state': 'unknown', }), }), @@ -6418,21 +6455,21 @@ ]), 'disabled_by': None, 'entry_type': None, - 'hw_version': '', + 'hw_version': '1.0.0', 'identifiers': list([ list([ 'homekit_controller:accessory-id', - '00:00:00:00:00:00:aid:1256851357', + '00:00:00:00:00:00:aid:878448248', ]), ]), 'is_new': False, - 'manufacturer': 'Home Assistant', - 'model': 'Fan', - 'name': 'Living Room Fan', + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Kitchen Window', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '0.104.0.dev0', + 'sw_version': '3.6.2', }), 'entities': list([ dict({ @@ -6446,7 +6483,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.living_room_fan_identify', + 'entity_id': 'button.kitchen_window_identify', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -6455,19 +6492,19 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Living Room Fan Identify', + 'original_name': 'Kitchen Window Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unique_id': '00:00:00:00:00:00_878448248_1_2', 'unit_of_measurement': None, }), 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Living Room Fan Identify', + 'friendly_name': 'Kitchen Window Identify', }), - 'entity_id': 'button.living_room_fan_identify', + 'entity_id': 'button.kitchen_window_identify', 'state': 'unknown', }), }), @@ -6476,15 +6513,13 @@ 'aliases': list([ ]), 'area_id': None, - 'capabilities': dict({ - 'preset_modes': None, - }), + 'capabilities': None, 'config_entry_id': 'TestData', 'device_class': None, 'disabled_by': None, - 'domain': 'fan', + 'domain': 'cover', 'entity_category': None, - 'entity_id': 'fan.living_room_fan', + 'entity_id': 'cover.kitchen_window', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -6493,33 +6528,71 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Living Room Fan', + 'original_name': 'Kitchen Window', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unique_id': '00:00:00:00:00:00_878448248_13', 'unit_of_measurement': None, }), 'state': dict({ 'attributes': dict({ - 'direction': 'forward', - 'friendly_name': 'Living Room Fan', - 'percentage': 0, - 'percentage_step': 1.0, - 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'current_position': 100, + 'friendly_name': 'Kitchen Window', + 'supported_features': , }), - 'entity_id': 'fan.living_room_fan', - 'state': 'off', + 'entity_id': 'cover.kitchen_window', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Kitchen Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_878448248_9', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Kitchen Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.kitchen_window_battery', + 'state': '100', }), }), ]), }), ]) # --- -# name: test_snapshots[home_assistant_bridge_fan] +# name: test_snapshots[home_assistant_bridge_basic_fan] list([ dict({ 'device': dict({ @@ -6785,7 +6858,7 @@ 'original_name': 'Living Room Fan', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_8', 'unit_of_measurement': None, @@ -6794,12 +6867,11 @@ 'attributes': dict({ 'direction': 'forward', 'friendly_name': 'Living Room Fan', - 'oscillating': False, 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, 'preset_modes': None, - 'supported_features': , + 'supported_features': , }), 'entity_id': 'fan.living_room_fan', 'state': 'off', @@ -6809,7 +6881,7 @@ }), ]) # --- -# name: test_snapshots[home_assistant_bridge_fan_one_removed] +# name: test_snapshots[home_assistant_bridge_basic_heater_cooler] list([ dict({ 'device': dict({ @@ -6826,17 +6898,17 @@ 'identifiers': list([ list([ 'homekit_controller:accessory-id', - '00:00:00:00:00:00:aid:1', + '00:00:00:00:00:00:aid:1233851541', ]), ]), 'is_new': False, - 'manufacturer': 'Home Assistant', - 'model': 'Bridge', - 'name': 'Home Assistant Bridge', + 'manufacturer': 'Lookin', + 'model': 'Climate Control', + 'name': '89 Living Room', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '0.104.0.dev0', + 'sw_version': '2024.2.0', }), 'entities': list([ dict({ @@ -6850,7 +6922,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.home_assistant_bridge_identify', + 'entity_id': 'button.89_living_room_identify', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -6859,64 +6931,44 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Home Assistant Bridge Identify', + 'original_name': '89 Living Room Identify', 'platform': 'homekit_controller', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unique_id': '00:00:00:00:00:00_1233851541_1_163', 'unit_of_measurement': None, }), 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Home Assistant Bridge Identify', + 'friendly_name': '89 Living Room Identify', }), - 'entity_id': 'button.home_assistant_bridge_identify', + 'entity_id': 'button.89_living_room_identify', 'state': 'unknown', }), }), - ]), - }), - dict({ - 'device': dict({ - 'area_id': None, - 'config_entries': list([ - 'TestData', - ]), - 'configuration_url': None, - 'connections': list([ - ]), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': '', - 'identifiers': list([ - list([ - 'homekit_controller:accessory-id', - '00:00:00:00:00:00:aid:1256851357', - ]), - ]), - 'is_new': False, - 'manufacturer': 'Home Assistant', - 'model': 'Fan', - 'name': 'Living Room Fan', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '0.104.0.dev0', - }), - 'entities': list([ dict({ 'entry': dict({ 'aliases': list([ ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), 'config_entry_id': 'TestData', 'device_class': None, 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.living_room_fan_identify', + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.89_living_room', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -6925,20 +6977,32 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Living Room Fan Identify', + 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unique_id': '00:00:00:00:00:00_1233851541_169', 'unit_of_measurement': None, }), 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Living Room Fan Identify', + 'current_temperature': 22.8, + 'friendly_name': '89 Living Room', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, }), - 'entity_id': 'button.living_room_fan_identify', - 'state': 'unknown', + 'entity_id': 'climate.89_living_room', + 'state': 'heat_cool', }), }), dict({ @@ -6954,7 +7018,7 @@ 'disabled_by': None, 'domain': 'fan', 'entity_category': None, - 'entity_id': 'fan.living_room_fan', + 'entity_id': 'fan.89_living_room', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -6963,27 +7027,2341 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Living Room Fan', + 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unique_id': '00:00:00:00:00:00_1233851541_175', 'unit_of_measurement': None, }), 'state': dict({ 'attributes': dict({ - 'direction': 'forward', - 'friendly_name': 'Living Room Fan', + 'friendly_name': '89 Living Room', 'oscillating': False, - 'percentage': 0, - 'percentage_step': 1.0, + 'percentage': 33, + 'percentage_step': 33.333333333333336, 'preset_mode': None, 'preset_modes': None, - 'supported_features': , + 'supported_features': , }), - 'entity_id': 'fan.living_room_fan', - 'state': 'off', + 'entity_id': 'fan.89_living_room', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.89_living_room_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': '89 Living Room Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1233851541_169_174', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': '89 Living Room Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.89_living_room_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.89_living_room_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '89 Living Room Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169_180', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': '89 Living Room Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.89_living_room_current_humidity', + 'state': '60', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.89_living_room_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '89 Living Room Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169_172', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': '89 Living Room Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.89_living_room_current_temperature', + 'state': '22.8', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_basic_light] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '9.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3982136094', + ]), + ]), + 'is_new': False, + 'manufacturer': 'FirstAlert', + 'model': '1039102', + 'name': 'Laundry Smoke ED78', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.4.84', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.laundry_smoke_ed78_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Laundry Smoke ED78 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_1_597', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Laundry Smoke ED78 Identify', + }), + 'entity_id': 'button.laundry_smoke_ed78_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.laundry_smoke_ed78', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Laundry Smoke ED78', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_608', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Laundry Smoke ED78', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.laundry_smoke_ed78', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundry_smoke_ed78_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Laundry Smoke ED78 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_604', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Laundry Smoke ED78 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.laundry_smoke_ed78_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_cover] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:123016423', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Family Room North', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.6.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.family_room_north_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Family Room North Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_123016423_1_155', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Family Room North Identify', + }), + 'entity_id': 'button.family_room_north_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.family_room_north', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Family Room North', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_123016423_166', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 98, + 'friendly_name': 'Family Room North', + 'supported_features': , + }), + 'entity_id': 'cover.family_room_north', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.family_room_north_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Family Room North Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_123016423_162', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Family Room North Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.family_room_north_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:878448248', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Kitchen Window', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.6.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.kitchen_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_878448248_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Kitchen Window Identify', + }), + 'entity_id': 'button.kitchen_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kitchen_window', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Window', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_878448248_13', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'Kitchen Window', + 'supported_features': , + }), + 'entity_id': 'cover.kitchen_window', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Kitchen Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_878448248_9', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Kitchen Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.kitchen_window_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:766313939', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Ceiling Fan', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ceiling_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan Identify', + }), + 'entity_id': 'button.ceiling_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan', + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.ceiling_fan', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'Home Assistant Bridge', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.home_assistant_bridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home Assistant Bridge Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Home Assistant Bridge Identify', + }), + 'entity_id': 'button.home_assistant_bridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1256851357', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Living Room Fan', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Living Room Fan Identify', + }), + 'entity_id': 'button.living_room_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'Living Room Fan', + 'oscillating': False, + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.living_room_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_fan_one_removed] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'Home Assistant Bridge', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.home_assistant_bridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home Assistant Bridge Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Home Assistant Bridge Identify', + }), + 'entity_id': 'button.home_assistant_bridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1256851357', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Living Room Fan', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Living Room Fan Identify', + }), + 'entity_id': 'button.living_room_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'Living Room Fan', + 'oscillating': False, + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.living_room_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_heater_cooler] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1233851541', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lookin', + 'model': 'Climate Control', + 'name': '89 Living Room', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.89_living_room_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '89 Living Room Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_1_163', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': '89 Living Room Identify', + }), + 'entity_id': 'button.89_living_room_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'vertical', + ]), + 'target_temp_step': 1.0, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.89_living_room', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '89 Living Room', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_temperature': 22.8, + 'friendly_name': '89 Living Room', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_mode': 'vertical', + 'swing_modes': list([ + 'off', + 'vertical', + ]), + 'target_temp_step': 1.0, + }), + 'entity_id': 'climate.89_living_room', + 'state': 'heat_cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.89_living_room', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '89 Living Room', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_175', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': '89 Living Room', + 'oscillating': False, + 'percentage': 33, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.89_living_room', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.89_living_room_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': '89 Living Room Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1233851541_169_174', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': '89 Living Room Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.89_living_room_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.89_living_room_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '89 Living Room Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169_180', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': '89 Living Room Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.89_living_room_current_humidity', + 'state': '60', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.89_living_room_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '89 Living Room Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169_172', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': '89 Living Room Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.89_living_room_current_temperature', + 'state': '22.8', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_humidifier] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:293334836', + ]), + ]), + 'is_new': False, + 'manufacturer': 'switchbot', + 'model': 'WoHumi', + 'name': 'Humidifier 182A', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.humidifier_182a_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Humidifier 182A Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Humidifier 182A Identify', + }), + 'entity_id': 'button.humidifier_182a_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.humidifier_182a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier 182A', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 0, + 'device_class': 'humidifier', + 'friendly_name': 'Humidifier 182A', + 'humidity': 45, + 'max_humidity': 100, + 'min_humidity': 0, + 'mode': 'normal', + 'supported_features': , + }), + 'entity_id': 'humidifier.humidifier_182a', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_182a_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier 182A Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_8_9', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 182A Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.humidifier_182a_current_humidity', + 'state': '0', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_humidifier_new_range] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:293334836', + ]), + ]), + 'is_new': False, + 'manufacturer': 'switchbot', + 'model': 'WoHumi', + 'name': 'Humidifier 182A', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.humidifier_182a_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Humidifier 182A Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Humidifier 182A Identify', + }), + 'entity_id': 'button.humidifier_182a_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 80, + 'min_humidity': 20, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.humidifier_182a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier 182A', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 0, + 'device_class': 'humidifier', + 'friendly_name': 'Humidifier 182A', + 'humidity': 45, + 'max_humidity': 80, + 'min_humidity': 20, + 'mode': 'normal', + 'supported_features': , + }), + 'entity_id': 'humidifier.humidifier_182a', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_182a_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier 182A Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_8_9', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 182A Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.humidifier_182a_current_humidity', + 'state': '0', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_light] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '9.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3982136094', + ]), + ]), + 'is_new': False, + 'manufacturer': 'FirstAlert', + 'model': '1039102', + 'name': 'Laundry Smoke ED78', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.4.84', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.laundry_smoke_ed78_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Laundry Smoke ED78 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_1_597', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Laundry Smoke ED78 Identify', + }), + 'entity_id': 'button.laundry_smoke_ed78_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.laundry_smoke_ed78', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Laundry Smoke ED78', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_608', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Laundry Smoke ED78', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'entity_id': 'light.laundry_smoke_ed78', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundry_smoke_ed78_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Laundry Smoke ED78 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_604', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Laundry Smoke ED78 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.laundry_smoke_ed78_battery', + 'state': '100', }), }), ]), diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index 44157c3220377b..9e08c6fed0a66f 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -1,10 +1,6 @@ """Make sure that ConnectSense Smart Outlet2 / In-Wall Outlet is enumerated properly.""" from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - ELECTRIC_CURRENT_AMPERE, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, -) +from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from ..common import ( @@ -39,7 +35,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_13_18", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.03", ), EntityTestInfo( @@ -47,7 +43,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_13_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -55,7 +51,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_13_20", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="379.69299", ), EntityTestInfo( @@ -69,7 +65,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_25_30", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.05", ), EntityTestInfo( @@ -77,7 +73,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_25_31", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -85,7 +81,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_25_32", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="175.85001", ), EntityTestInfo( @@ -118,7 +114,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_13_18", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.03", ), EntityTestInfo( @@ -126,7 +122,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_13_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -134,7 +130,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_13_20", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="379.69299", ), EntityTestInfo( @@ -148,7 +144,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_25_30", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.05", ), EntityTestInfo( @@ -156,7 +152,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_25_31", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -164,7 +160,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_25_32", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="175.85001", ), EntityTestInfo( diff --git a/tests/components/homekit_controller/specific_devices/test_cover_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_cover_that_changes_features.py new file mode 100644 index 00000000000000..87948c92214fda --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_cover_that_changes_features.py @@ -0,0 +1,54 @@ +"""Test for a Home Assistant bridge that changes cover features at runtime.""" + + +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_cover_add_feature_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that new features can be added at runtime.""" + + # Set up a basic cover that does not support position + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_cover.json" + ) + await setup_test_accessories(hass, accessories) + + cover = entity_registry.async_get("cover.family_room_north") + assert cover.unique_id == "00:00:00:00:00:00_123016423_166" + + cover_state = hass.states.get("cover.family_room_north") + assert ( + cover_state.attributes[ATTR_SUPPORTED_FEATURES] + is CoverEntityFeature.OPEN + | CoverEntityFeature.STOP + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + + cover = entity_registry.async_get("cover.family_room_north") + assert cover.unique_id == "00:00:00:00:00:00_123016423_166" + + # Now change the config to remove stop + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_cover.json" + ) + await device_config_changed(hass, accessories) + + cover_state = hass.states.get("cover.family_room_north") + assert ( + cover_state.attributes[ATTR_SUPPORTED_FEATURES] + is CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 7b721e76bbaead..723881ac182d98 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -8,11 +8,7 @@ from aiohomekit import AccessoryNotFoundError from aiohomekit.testing import FakePairing -from homeassistant.components.climate import ( - SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) +from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.sensor import SensorStateClass from homeassistant.config_entries import ConfigEntryState from homeassistant.const import UnitOfTemperature @@ -108,9 +104,9 @@ async def test_ecobee3_setup(hass: HomeAssistant) -> None: friendly_name="HomeW", unique_id="00:00:00:00:00:00_1_16", supported_features=( - SUPPORT_TARGET_TEMPERATURE - | SUPPORT_TARGET_TEMPERATURE_RANGE - | SUPPORT_TARGET_HUMIDITY + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY ), capabilities={ "hvac_modes": ["off", "heat", "cool", "heat_cool"], @@ -142,7 +138,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 +161,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 +174,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 +214,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 +251,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 +305,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 bae0c0e4ff1dd9..1dc8e9ace68149 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 @@ ) -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_heater_cooler_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py new file mode 100644 index 00000000000000..79b07512c6790b --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py @@ -0,0 +1,48 @@ +"""Test for a Home Assistant bridge that changes climate features at runtime.""" + + +from homeassistant.components.climate import ATTR_SWING_MODES, ClimateEntityFeature +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_cover_add_feature_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that new features can be added at runtime.""" + + # Set up a basic heater cooler that does not support swing mode + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_heater_cooler.json" + ) + await setup_test_accessories(hass, accessories) + + climate = entity_registry.async_get("climate.89_living_room") + assert climate.unique_id == "00:00:00:00:00:00_1233851541_169" + + climate_state = hass.states.get("climate.89_living_room") + assert climate_state.attributes[ATTR_SUPPORTED_FEATURES] is ClimateEntityFeature(0) + assert ATTR_SWING_MODES not in climate_state.attributes + + climate = entity_registry.async_get("climate.89_living_room") + assert climate.unique_id == "00:00:00:00:00:00_1233851541_169" + + # Now change the config to add swing mode + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_heater_cooler.json" + ) + await device_config_changed(hass, accessories) + + climate_state = hass.states.get("climate.89_living_room") + assert ( + climate_state.attributes[ATTR_SUPPORTED_FEATURES] + is ClimateEntityFeature.SWING_MODE + ) + assert climate_state.attributes[ATTR_SWING_MODES] == ["off", "vertical"] diff --git a/tests/components/homekit_controller/specific_devices/test_humidifier_that_changes_value_range.py b/tests/components/homekit_controller/specific_devices/test_humidifier_that_changes_value_range.py new file mode 100644 index 00000000000000..518bcbbef388f6 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_humidifier_that_changes_value_range.py @@ -0,0 +1,44 @@ +"""Test for a Home Assistant bridge that changes humidifier min/max at runtime.""" + + +from homeassistant.components.humidifier import ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_humidifier_change_range_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that min max can be changed at runtime.""" + + # Set up a basic humidifier + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_humidifier.json" + ) + await setup_test_accessories(hass, accessories) + + humidifier = entity_registry.async_get("humidifier.humidifier_182a") + assert humidifier.unique_id == "00:00:00:00:00:00_293334836_8" + + humidifier_state = hass.states.get("humidifier.humidifier_182a") + assert humidifier_state.attributes[ATTR_MIN_HUMIDITY] == 0 + assert humidifier_state.attributes[ATTR_MAX_HUMIDITY] == 100 + + cover = entity_registry.async_get("humidifier.humidifier_182a") + assert cover.unique_id == "00:00:00:00:00:00_293334836_8" + + # Now change min/max values + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_humidifier_new_range.json" + ) + await device_config_changed(hass, accessories) + + humidifier_state = hass.states.get("humidifier.humidifier_182a") + assert humidifier_state.attributes[ATTR_MIN_HUMIDITY] == 20 + assert humidifier_state.attributes[ATTR_MAX_HUMIDITY] == 80 diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index 2c2c0b5e1c57af..baee3082106055 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -21,7 +21,7 @@ @pytest.mark.parametrize("failure_cls", [AccessoryDisconnectedError, EncryptionError]) -async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> None: +async def test_recover_from_failure(hass: HomeAssistant, failure_cls) -> None: """Test that entity actually recovers from a network connection drop. See https://github.com/home-assistant/core/issues/18949 diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py index 44293ac439ce99..7114d1380390da 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py @@ -5,7 +5,7 @@ It should have 2 entities - the actual switch and a sensor for power usage. """ from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import POWER_WATT +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from ..common import ( @@ -51,7 +51,7 @@ async def test_koogeek_sw2_setup(hass: HomeAssistant) -> None: entity_id="sensor.koogeek_sw2_187a91_power", friendly_name="Koogeek-SW2-187A91 Power", unique_id="00:00:00:00:00:00_1_14_18", - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="0", ), diff --git a/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py new file mode 100644 index 00000000000000..54dc900c130146 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py @@ -0,0 +1,42 @@ +"""Test for a Home Assistant bridge that changes light features at runtime.""" + + +from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_light_add_feature_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that new features can be added at runtime.""" + + # Set up a basic light that does not support color + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_light.json" + ) + await setup_test_accessories(hass, accessories) + + light = entity_registry.async_get("light.laundry_smoke_ed78") + assert light.unique_id == "00:00:00:00:00:00_3982136094_608" + + light_state = hass.states.get("light.laundry_smoke_ed78") + assert light_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] + + light = entity_registry.async_get("light.laundry_smoke_ed78") + assert light.unique_id == "00:00:00:00:00:00_3982136094_608" + + # Now add hue and saturation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_light.json" + ) + await device_config_changed(hass, accessories) + + light_state = hass.states.get("light.laundry_smoke_ed78") + assert light_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] 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 9c51707b809319..b42a7652c1c9e0 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py @@ -1,6 +1,6 @@ """Make sure that existing VOCOlinc VP3 support isn't broken.""" from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import POWER_WATT +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -14,10 +14,12 @@ ) -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", @@ -56,7 +58,7 @@ async def test_vocolinc_vp3_setup(hass: HomeAssistant) -> None: entity_id="sensor.original_vocolinc_vp3_power", friendly_name="VOCOlinc-VP3-123456 Power", unique_id="00:00:00:00:00:00_1_48_97", - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="0", ), diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index 2ca74f8fe7549a..19991d7cc1399c 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -26,7 +26,7 @@ def create_security_system_service(accessory): targ_state.value = 50 -async def test_switch_change_alarm_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_alarm_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit alarm on and off again.""" helper = await setup_test_component(hass, create_security_system_service) @@ -83,7 +83,7 @@ async def test_switch_change_alarm_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_read_alarm_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_alarm_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit alarm accessory.""" helper = await setup_test_component(hass, create_security_system_service) @@ -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 +) -> 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 0a1fd9fc52db7e..92c303cab45a96 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -17,7 +17,7 @@ def create_motion_sensor_service(accessory): cur_state.value = 0 -async def test_motion_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_motion_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit motion sensor accessory.""" helper = await setup_test_component(hass, create_motion_sensor_service) @@ -44,7 +44,7 @@ def create_contact_sensor_service(accessory): cur_state.value = 0 -async def test_contact_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_contact_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit contact accessory.""" helper = await setup_test_component(hass, create_contact_sensor_service) @@ -71,7 +71,7 @@ def create_smoke_sensor_service(accessory): cur_state.value = 0 -async def test_smoke_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_smoke_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit contact accessory.""" helper = await setup_test_component(hass, create_smoke_sensor_service) @@ -98,7 +98,7 @@ def create_carbon_monoxide_sensor_service(accessory): cur_state.value = 0 -async def test_carbon_monoxide_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_carbon_monoxide_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit contact accessory.""" helper = await setup_test_component(hass, create_carbon_monoxide_sensor_service) @@ -127,7 +127,7 @@ def create_occupancy_sensor_service(accessory): cur_state.value = 0 -async def test_occupancy_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_occupancy_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit occupancy sensor accessory.""" helper = await setup_test_component(hass, create_occupancy_sensor_service) @@ -154,7 +154,7 @@ def create_leak_sensor_service(accessory): cur_state.value = 0 -async def test_leak_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_leak_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit leak sensor accessory.""" helper = await setup_test_component(hass, create_leak_sensor_service) @@ -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 +) -> 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 fd21498cf27ba3..57592fb7a2789e 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 +) -> 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 27bc470a953672..f74f2e62772b37 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 +) -> None: """Test migrating entity unique ids.""" - entity_registry = er.async_get(hass) aid = get_next_aid() camera = entity_registry.async_get_or_create( "camera", @@ -32,7 +33,7 @@ async def test_migrate_unique_ids(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit camera.""" helper = await setup_test_component(hass, create_camera) @@ -40,7 +41,7 @@ async def test_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "idle" -async def test_get_image(hass: HomeAssistant, utcnow) -> None: +async def test_get_image(hass: HomeAssistant) -> None: """Test getting a JPEG from a camera.""" helper = await setup_test_component(hass, create_camera) image = await camera.async_get_image(hass, helper.entity_id) diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 0f6a3633bd47f5..e4fe754013a8e5 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -72,9 +72,7 @@ def create_thermostat_service_min_max(accessory): char.maxValue = 1 -async def test_climate_respect_supported_op_modes_1( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_respect_supported_op_modes_1(hass: HomeAssistant) -> None: """Test that climate respects minValue/maxValue hints.""" helper = await setup_test_component(hass, create_thermostat_service_min_max) state = await helper.poll_and_get_state() @@ -89,16 +87,14 @@ def create_thermostat_service_valid_vals(accessory): char.valid_values = [0, 1, 2] -async def test_climate_respect_supported_op_modes_2( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_respect_supported_op_modes_2(hass: HomeAssistant) -> None: """Test that climate respects validValue hints.""" helper = await setup_test_component(hass, create_thermostat_service_valid_vals) state = await helper.poll_and_get_state() assert state.attributes["hvac_modes"] == ["off", "heat", "cool"] -async def test_climate_change_thermostat_state(hass: HomeAssistant, utcnow) -> None: +async def test_climate_change_thermostat_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -181,9 +177,7 @@ async def test_climate_change_thermostat_state(hass: HomeAssistant, utcnow) -> N ) -async def test_climate_check_min_max_values_per_mode( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_check_min_max_values_per_mode(hass: HomeAssistant) -> None: """Test that we we get the appropriate min/max values for each mode.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -218,9 +212,7 @@ async def test_climate_check_min_max_values_per_mode( assert climate_state.attributes["max_temp"] == 40 -async def test_climate_change_thermostat_temperature( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_change_thermostat_temperature(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -251,9 +243,7 @@ async def test_climate_change_thermostat_temperature( ) -async def test_climate_change_thermostat_temperature_range( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_change_thermostat_temperature_range(hass: HomeAssistant) -> None: """Test that we can set separate heat and cool setpoints in heat_cool mode.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -287,7 +277,7 @@ async def test_climate_change_thermostat_temperature_range( async def test_climate_change_thermostat_temperature_range_iphone( - hass: HomeAssistant, utcnow + hass: HomeAssistant, ) -> None: """Test that we can set all three set points at once (iPhone heat_cool mode support).""" helper = await setup_test_component(hass, create_thermostat_service) @@ -322,7 +312,7 @@ async def test_climate_change_thermostat_temperature_range_iphone( async def test_climate_cannot_set_thermostat_temp_range_in_wrong_mode( - hass: HomeAssistant, utcnow + hass: HomeAssistant, ) -> None: """Test that we cannot set range values when not in heat_cool mode.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -381,7 +371,7 @@ def create_thermostat_single_set_point_auto(accessory): async def test_climate_check_min_max_values_per_mode_sspa_device( - hass: HomeAssistant, utcnow + hass: HomeAssistant, ) -> None: """Test appropriate min/max values for each mode on sspa devices.""" helper = await setup_test_component(hass, create_thermostat_single_set_point_auto) @@ -417,9 +407,7 @@ async def test_climate_check_min_max_values_per_mode_sspa_device( assert climate_state.attributes["max_temp"] == 35 -async def test_climate_set_thermostat_temp_on_sspa_device( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_set_thermostat_temp_on_sspa_device(hass: HomeAssistant) -> None: """Test setting temperature in different modes on device with single set point in auto.""" helper = await setup_test_component(hass, create_thermostat_single_set_point_auto) @@ -473,7 +461,7 @@ async def test_climate_set_thermostat_temp_on_sspa_device( ) -async def test_climate_set_mode_via_temp(hass: HomeAssistant, utcnow) -> None: +async def test_climate_set_mode_via_temp(hass: HomeAssistant) -> None: """Test setting temperature and mode at same tims.""" helper = await setup_test_component(hass, create_thermostat_single_set_point_auto) @@ -514,7 +502,7 @@ async def test_climate_set_mode_via_temp(hass: HomeAssistant, utcnow) -> None: ) -async def test_climate_change_thermostat_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_climate_change_thermostat_humidity(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -545,7 +533,7 @@ async def test_climate_change_thermostat_humidity(hass: HomeAssistant, utcnow) - ) -async def test_climate_read_thermostat_state(hass: HomeAssistant, utcnow) -> None: +async def test_climate_read_thermostat_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -602,7 +590,7 @@ async def test_climate_read_thermostat_state(hass: HomeAssistant, utcnow) -> Non assert state.state == HVACMode.HEAT_COOL -async def test_hvac_mode_vs_hvac_action(hass: HomeAssistant, utcnow) -> None: +async def test_hvac_mode_vs_hvac_action(hass: HomeAssistant) -> None: """Check that we haven't conflated hvac_mode and hvac_action.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -639,9 +627,7 @@ async def test_hvac_mode_vs_hvac_action(hass: HomeAssistant, utcnow) -> None: assert state.attributes["hvac_action"] == "heating" -async def test_hvac_mode_vs_hvac_action_current_mode_wrong( - hass: HomeAssistant, utcnow -) -> None: +async def test_hvac_mode_vs_hvac_action_current_mode_wrong(hass: HomeAssistant) -> None: """Check that we cope with buggy HEATING_COOLING_CURRENT.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -705,9 +691,7 @@ def create_heater_cooler_service_min_max(accessory): char.maxValue = 2 -async def test_heater_cooler_respect_supported_op_modes_1( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_respect_supported_op_modes_1(hass: HomeAssistant) -> None: """Test that climate respects minValue/maxValue hints.""" helper = await setup_test_component(hass, create_heater_cooler_service_min_max) state = await helper.poll_and_get_state() @@ -722,18 +706,14 @@ def create_theater_cooler_service_valid_vals(accessory): char.valid_values = [1, 2] -async def test_heater_cooler_respect_supported_op_modes_2( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_respect_supported_op_modes_2(hass: HomeAssistant) -> None: """Test that climate respects validValue hints.""" helper = await setup_test_component(hass, create_theater_cooler_service_valid_vals) state = await helper.poll_and_get_state() assert state.attributes["hvac_modes"] == ["heat", "cool", "off"] -async def test_heater_cooler_change_thermostat_state( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_change_thermostat_state(hass: HomeAssistant) -> None: """Test that we can change the operational mode.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -790,7 +770,7 @@ async def test_heater_cooler_change_thermostat_state( ) -async def test_can_turn_on_after_off(hass: HomeAssistant, utcnow) -> None: +async def test_can_turn_on_after_off(hass: HomeAssistant) -> None: """Test that we always force device from inactive to active when setting mode. This is a regression test for #81863. @@ -825,9 +805,7 @@ async def test_can_turn_on_after_off(hass: HomeAssistant, utcnow) -> None: ) -async def test_heater_cooler_change_thermostat_temperature( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_change_thermostat_temperature(hass: HomeAssistant) -> None: """Test that we can change the target temperature.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -870,7 +848,7 @@ async def test_heater_cooler_change_thermostat_temperature( ) -async def test_heater_cooler_change_fan_speed(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_change_fan_speed(hass: HomeAssistant) -> None: """Test that we can change the target fan speed.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -918,7 +896,7 @@ async def test_heater_cooler_change_fan_speed(hass: HomeAssistant, utcnow) -> No ) -async def test_heater_cooler_read_fan_speed(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_read_fan_speed(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -967,7 +945,7 @@ async def test_heater_cooler_read_fan_speed(hass: HomeAssistant, utcnow) -> None assert state.attributes["fan_mode"] == "high" -async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1021,9 +999,7 @@ async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant, utcnow) assert state.state == HVACMode.HEAT_COOL -async def test_heater_cooler_hvac_mode_vs_hvac_action( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_hvac_mode_vs_hvac_action(hass: HomeAssistant) -> None: """Check that we haven't conflated hvac_mode and hvac_action.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1062,7 +1038,7 @@ async def test_heater_cooler_hvac_mode_vs_hvac_action( assert state.attributes["hvac_action"] == "heating" -async def test_heater_cooler_change_swing_mode(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_change_swing_mode(hass: HomeAssistant) -> None: """Test that we can change the swing mode.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1093,7 +1069,7 @@ async def test_heater_cooler_change_swing_mode(hass: HomeAssistant, utcnow) -> N ) -async def test_heater_cooler_turn_off(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_turn_off(hass: HomeAssistant) -> None: """Test that both hvac_action and hvac_mode return "off" when turned off.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1112,9 +1088,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 +) -> 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 e5949978215d4f..08169c006aebb5 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -90,7 +90,9 @@ class DeviceMigrationTest: @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 5a389311daaf28..7d004a8a428e91 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -93,7 +93,7 @@ def create_window_covering_service_with_v_tilt_2(accessory): tilt_target.maxValue = 0 -async def test_change_window_cover_state(hass: HomeAssistant, utcnow) -> None: +async def test_change_window_cover_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit alarm on and off again.""" helper = await setup_test_component(hass, create_window_covering_service) @@ -118,7 +118,7 @@ async def test_change_window_cover_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_window_cover_state(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit alarm accessory.""" helper = await setup_test_component(hass, create_window_covering_service) @@ -151,7 +151,7 @@ async def test_read_window_cover_state(hass: HomeAssistant, utcnow) -> None: assert state.attributes["obstruction-detected"] is True -async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant) -> None: """Test that horizontal tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt @@ -166,7 +166,7 @@ async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> assert state.attributes["current_tilt_position"] == 83 -async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant) -> None: """Test that horizontal tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt_2 @@ -181,7 +181,7 @@ async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant, utcnow) assert state.attributes["current_tilt_position"] == 83 -async def test_read_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_vertical(hass: HomeAssistant) -> None: """Test that vertical tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt @@ -196,7 +196,7 @@ async def test_read_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> N assert state.attributes["current_tilt_position"] == 83 -async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant) -> None: """Test that vertical tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt_2 @@ -211,7 +211,7 @@ async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> assert state.attributes["current_tilt_position"] == 83 -async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> None: +async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant) -> None: """Test that horizontal tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt @@ -232,9 +232,7 @@ async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) - ) -async def test_write_window_cover_tilt_horizontal_2( - hass: HomeAssistant, utcnow -) -> None: +async def test_write_window_cover_tilt_horizontal_2(hass: HomeAssistant) -> None: """Test that horizontal tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt_2 @@ -255,7 +253,7 @@ async def test_write_window_cover_tilt_horizontal_2( ) -async def test_write_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> None: +async def test_write_window_cover_tilt_vertical(hass: HomeAssistant) -> None: """Test that vertical tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt @@ -276,7 +274,7 @@ async def test_write_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> ) -async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> None: +async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant) -> None: """Test that vertical tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt_2 @@ -297,7 +295,7 @@ async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) - ) -async def test_window_cover_stop(hass: HomeAssistant, utcnow) -> None: +async def test_window_cover_stop(hass: HomeAssistant) -> None: """Test that vertical tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt @@ -333,7 +331,7 @@ def create_garage_door_opener_service(accessory): return service -async def test_change_door_state(hass: HomeAssistant, utcnow) -> None: +async def test_change_door_state(hass: HomeAssistant) -> None: """Test that we can turn open and close a HomeKit garage door.""" helper = await setup_test_component(hass, create_garage_door_opener_service) @@ -358,7 +356,7 @@ async def test_change_door_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_door_state(hass: HomeAssistant, utcnow) -> None: +async def test_read_door_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit garage door.""" helper = await setup_test_component(hass, create_garage_door_opener_service) @@ -398,9 +396,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 +) -> 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 41b6a9fc7dc422..2f66a1eea26661 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -83,15 +83,17 @@ 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, +) -> 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 +134,17 @@ 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, +) -> 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 +184,17 @@ 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, +) -> 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 +234,17 @@ 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, + 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 +354,17 @@ 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, + 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 4b5372d980d798..c0a9ebbb8d4299 100644 --- a/tests/components/homekit_controller/test_diagnostics.py +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -1,5 +1,7 @@ """Test homekit_controller diagnostics.""" -from unittest.mock import ANY + +from syrupy import SnapshotAssertion +from syrupy.filters import props from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from homeassistant.core import HomeAssistant @@ -15,7 +17,9 @@ async def test_config_entry( - hass: HomeAssistant, hass_client: ClientSessionGenerator, utcnow + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") @@ -23,542 +27,22 @@ async def test_config_entry( diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "config-entry": { - "title": "test", - "version": 1, - "data": {"AccessoryPairingID": "00:00:00:00:00:00"}, - }, - "config-num": 0, - "entity-map": [ - { - "aid": 1, - "services": [ - { - "iid": 1, - "type": "0000003E-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 2, - "perms": ["pr"], - "format": "string", - "value": "Koogeek-LS1-20833F", - "description": "Name", - "maxLen": 64, - }, - { - "type": "00000020-0000-1000-8000-0026BB765291", - "iid": 3, - "perms": ["pr"], - "format": "string", - "value": "Koogeek", - "description": "Manufacturer", - "maxLen": 64, - }, - { - "type": "00000021-0000-1000-8000-0026BB765291", - "iid": 4, - "perms": ["pr"], - "format": "string", - "value": "LS1", - "description": "Model", - "maxLen": 64, - }, - { - "type": "00000030-0000-1000-8000-0026BB765291", - "iid": 5, - "perms": ["pr"], - "format": "string", - "value": "**REDACTED**", - "description": "Serial Number", - "maxLen": 64, - }, - { - "type": "00000014-0000-1000-8000-0026BB765291", - "iid": 6, - "perms": ["pw"], - "format": "bool", - "description": "Identify", - }, - { - "type": "00000052-0000-1000-8000-0026BB765291", - "iid": 23, - "perms": ["pr"], - "format": "string", - "value": "2.2.15", - "description": "Firmware Revision", - "maxLen": 64, - }, - ], - }, - { - "iid": 7, - "type": "00000043-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000025-0000-1000-8000-0026BB765291", - "iid": 8, - "perms": ["pr", "pw", "ev"], - "format": "bool", - "value": False, - "description": "On", - }, - { - "type": "00000013-0000-1000-8000-0026BB765291", - "iid": 9, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 44, - "description": "Hue", - "unit": "arcdegrees", - "minValue": 0, - "maxValue": 359, - "minStep": 1, - }, - { - "type": "0000002F-0000-1000-8000-0026BB765291", - "iid": 10, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 0, - "description": "Saturation", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000008-0000-1000-8000-0026BB765291", - "iid": 11, - "perms": ["pr", "pw", "ev"], - "format": "int", - "value": 100, - "description": "Brightness", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 12, - "perms": ["pr"], - "format": "string", - "value": "Light Strip", - "description": "Name", - "maxLen": 64, - }, - ], - }, - { - "iid": 13, - "type": "4AAAF940-0DEC-11E5-B939-0800200C9A66", - "characteristics": [ - { - "type": "4AAAF942-0DEC-11E5-B939-0800200C9A66", - "iid": 14, - "perms": ["pr", "pw"], - "format": "tlv8", - "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "description": "TIMER_SETTINGS", - } - ], - }, - { - "iid": 15, - "type": "151909D0-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D2-3802-11E4-916C-0800200C9A66", - "iid": 16, - "perms": ["pr", "hd"], - "format": "string", - "value": "url,data", - "description": "FW Upgrade supported types", - "maxLen": 64, - }, - { - "type": "151909D1-3802-11E4-916C-0800200C9A66", - "iid": 17, - "perms": ["pw", "hd"], - "format": "string", - "description": "FW Upgrade URL", - "maxLen": 64, - }, - { - "type": "151909D6-3802-11E4-916C-0800200C9A66", - "iid": 18, - "perms": ["pr", "ev", "hd"], - "format": "int", - "value": 0, - "description": "FW Upgrade Status", - }, - { - "type": "151909D7-3802-11E4-916C-0800200C9A66", - "iid": 19, - "perms": ["pw", "hd"], - "format": "data", - "description": "FW Upgrade Data", - }, - ], - }, - { - "iid": 20, - "type": "151909D3-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D5-3802-11E4-916C-0800200C9A66", - "iid": 21, - "perms": ["pr", "pw"], - "format": "int", - "value": 0, - "description": "Timezone", - }, - { - "type": "151909D4-3802-11E4-916C-0800200C9A66", - "iid": 22, - "perms": ["pr", "pw"], - "format": "int", - "value": 1550348623, - "description": "Time value since Epoch", - }, - ], - }, - ], - } - ], - "devices": [ - { - "name": "Koogeek-LS1-20833F", - "model": "LS1", - "manfacturer": "Koogeek", - "sw_version": "2.2.15", - "hw_version": "", - "entities": [ - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": "diagnostic", - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Identify", - "state": { - "attributes": { - "friendly_name": "Koogeek-LS1-20833F Identify" - }, - "entity_id": "button.koogeek_ls1_20833f_identify", - "last_changed": ANY, - "last_updated": ANY, - "state": "unknown", - }, - "unit_of_measurement": None, - }, - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": None, - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Light Strip", - "state": { - "attributes": { - "friendly_name": "Koogeek-LS1-20833F Light Strip", - "supported_color_modes": ["hs"], - "supported_features": 0, - "brightness": None, - "color_mode": None, - "hs_color": None, - "rgb_color": None, - "xy_color": None, - }, - "entity_id": "light.koogeek_ls1_20833f_light_strip", - "last_changed": ANY, - "last_updated": ANY, - "state": "off", - }, - "unit_of_measurement": None, - }, - ], - } - ], - } + assert diag == snapshot(exclude=props("last_updated", "last_changed")) async def test_device( - hass: HomeAssistant, hass_client: ClientSessionGenerator, utcnow + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> 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) - assert diag == { - "config-entry": { - "title": "test", - "version": 1, - "data": {"AccessoryPairingID": "00:00:00:00:00:00"}, - }, - "config-num": 0, - "entity-map": [ - { - "aid": 1, - "services": [ - { - "iid": 1, - "type": "0000003E-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 2, - "perms": ["pr"], - "format": "string", - "value": "Koogeek-LS1-20833F", - "description": "Name", - "maxLen": 64, - }, - { - "type": "00000020-0000-1000-8000-0026BB765291", - "iid": 3, - "perms": ["pr"], - "format": "string", - "value": "Koogeek", - "description": "Manufacturer", - "maxLen": 64, - }, - { - "type": "00000021-0000-1000-8000-0026BB765291", - "iid": 4, - "perms": ["pr"], - "format": "string", - "value": "LS1", - "description": "Model", - "maxLen": 64, - }, - { - "type": "00000030-0000-1000-8000-0026BB765291", - "iid": 5, - "perms": ["pr"], - "format": "string", - "value": "**REDACTED**", - "description": "Serial Number", - "maxLen": 64, - }, - { - "type": "00000014-0000-1000-8000-0026BB765291", - "iid": 6, - "perms": ["pw"], - "format": "bool", - "description": "Identify", - }, - { - "type": "00000052-0000-1000-8000-0026BB765291", - "iid": 23, - "perms": ["pr"], - "format": "string", - "value": "2.2.15", - "description": "Firmware Revision", - "maxLen": 64, - }, - ], - }, - { - "iid": 7, - "type": "00000043-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000025-0000-1000-8000-0026BB765291", - "iid": 8, - "perms": ["pr", "pw", "ev"], - "format": "bool", - "value": False, - "description": "On", - }, - { - "type": "00000013-0000-1000-8000-0026BB765291", - "iid": 9, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 44, - "description": "Hue", - "unit": "arcdegrees", - "minValue": 0, - "maxValue": 359, - "minStep": 1, - }, - { - "type": "0000002F-0000-1000-8000-0026BB765291", - "iid": 10, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 0, - "description": "Saturation", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000008-0000-1000-8000-0026BB765291", - "iid": 11, - "perms": ["pr", "pw", "ev"], - "format": "int", - "value": 100, - "description": "Brightness", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 12, - "perms": ["pr"], - "format": "string", - "value": "Light Strip", - "description": "Name", - "maxLen": 64, - }, - ], - }, - { - "iid": 13, - "type": "4AAAF940-0DEC-11E5-B939-0800200C9A66", - "characteristics": [ - { - "type": "4AAAF942-0DEC-11E5-B939-0800200C9A66", - "iid": 14, - "perms": ["pr", "pw"], - "format": "tlv8", - "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "description": "TIMER_SETTINGS", - } - ], - }, - { - "iid": 15, - "type": "151909D0-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D2-3802-11E4-916C-0800200C9A66", - "iid": 16, - "perms": ["pr", "hd"], - "format": "string", - "value": "url,data", - "description": "FW Upgrade supported types", - "maxLen": 64, - }, - { - "type": "151909D1-3802-11E4-916C-0800200C9A66", - "iid": 17, - "perms": ["pw", "hd"], - "format": "string", - "description": "FW Upgrade URL", - "maxLen": 64, - }, - { - "type": "151909D6-3802-11E4-916C-0800200C9A66", - "iid": 18, - "perms": ["pr", "ev", "hd"], - "format": "int", - "value": 0, - "description": "FW Upgrade Status", - }, - { - "type": "151909D7-3802-11E4-916C-0800200C9A66", - "iid": 19, - "perms": ["pw", "hd"], - "format": "data", - "description": "FW Upgrade Data", - }, - ], - }, - { - "iid": 20, - "type": "151909D3-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D5-3802-11E4-916C-0800200C9A66", - "iid": 21, - "perms": ["pr", "pw"], - "format": "int", - "value": 0, - "description": "Timezone", - }, - { - "type": "151909D4-3802-11E4-916C-0800200C9A66", - "iid": 22, - "perms": ["pr", "pw"], - "format": "int", - "value": 1550348623, - "description": "Time value since Epoch", - }, - ], - }, - ], - } - ], - "device": { - "name": "Koogeek-LS1-20833F", - "model": "LS1", - "manfacturer": "Koogeek", - "sw_version": "2.2.15", - "hw_version": "", - "entities": [ - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": "diagnostic", - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Identify", - "state": { - "attributes": {"friendly_name": "Koogeek-LS1-20833F Identify"}, - "entity_id": "button.koogeek_ls1_20833f_identify", - "last_changed": ANY, - "last_updated": ANY, - "state": "unknown", - }, - "unit_of_measurement": None, - }, - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": None, - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Light Strip", - "state": { - "attributes": { - "friendly_name": "Koogeek-LS1-20833F Light Strip", - "supported_color_modes": ["hs"], - "supported_features": 0, - "brightness": None, - "color_mode": None, - "hs_color": None, - "rgb_color": None, - "xy_color": None, - }, - "entity_id": "light.koogeek_ls1_20833f_light_strip", - "last_changed": ANY, - "last_updated": ANY, - "state": "off", - }, - "unit_of_measurement": None, - }, - ], - }, - } + assert diag == snapshot(exclude=props("last_updated", "last_changed")) diff --git a/tests/components/homekit_controller/test_event.py b/tests/components/homekit_controller/test_event.py index 9731f429eaf092..a836fb1c669841 100644 --- a/tests/components/homekit_controller/test_event.py +++ b/tests/components/homekit_controller/test_event.py @@ -64,7 +64,7 @@ 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) -> None: """Test that remote is supported.""" helper = await setup_test_component(hass, create_remote) @@ -75,8 +75,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 +107,11 @@ 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) -> 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 +143,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 +) -> 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 9256128b2cbcf3..938f09c453ef0a 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -89,7 +89,7 @@ def create_fanv2_service_without_rotation_speed(accessory): swing_mode.value = 0 -async def test_fan_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_fan_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit fan accessory.""" helper = await setup_test_component(hass, create_fan_service) @@ -104,7 +104,7 @@ async def test_fan_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "on" -async def test_turn_on(hass: HomeAssistant, utcnow) -> None: +async def test_turn_on(hass: HomeAssistant) -> None: """Test that we can turn a fan on.""" helper = await setup_test_component(hass, create_fan_service) @@ -151,7 +151,7 @@ async def test_turn_on(hass: HomeAssistant, utcnow) -> None: ) -async def test_turn_on_off_without_rotation_speed(hass: HomeAssistant, utcnow) -> None: +async def test_turn_on_off_without_rotation_speed(hass: HomeAssistant) -> None: """Test that we can turn a fan on.""" helper = await setup_test_component( hass, create_fanv2_service_without_rotation_speed @@ -184,7 +184,7 @@ async def test_turn_on_off_without_rotation_speed(hass: HomeAssistant, utcnow) - ) -async def test_turn_off(hass: HomeAssistant, utcnow) -> None: +async def test_turn_off(hass: HomeAssistant) -> None: """Test that we can turn a fan off.""" helper = await setup_test_component(hass, create_fan_service) @@ -204,7 +204,7 @@ async def test_turn_off(hass: HomeAssistant, utcnow) -> None: ) -async def test_set_speed(hass: HomeAssistant, utcnow) -> None: +async def test_set_speed(hass: HomeAssistant) -> None: """Test that we set fan speed.""" helper = await setup_test_component(hass, create_fan_service) @@ -263,7 +263,7 @@ async def test_set_speed(hass: HomeAssistant, utcnow) -> None: ) -async def test_set_percentage(hass: HomeAssistant, utcnow) -> None: +async def test_set_percentage(hass: HomeAssistant) -> None: """Test that we set fan speed by percentage.""" helper = await setup_test_component(hass, create_fan_service) @@ -296,7 +296,7 @@ async def test_set_percentage(hass: HomeAssistant, utcnow) -> None: ) -async def test_speed_read(hass: HomeAssistant, utcnow) -> None: +async def test_speed_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fan_service) @@ -336,7 +336,7 @@ async def test_speed_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["percentage"] == 0 -async def test_set_direction(hass: HomeAssistant, utcnow) -> None: +async def test_set_direction(hass: HomeAssistant) -> None: """Test that we can set fan spin direction.""" helper = await setup_test_component(hass, create_fan_service) @@ -367,7 +367,7 @@ async def test_set_direction(hass: HomeAssistant, utcnow) -> None: ) -async def test_direction_read(hass: HomeAssistant, utcnow) -> None: +async def test_direction_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fan_service) @@ -382,7 +382,7 @@ async def test_direction_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["direction"] == "reverse" -async def test_fanv2_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_fanv2_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit fan accessory.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -397,7 +397,7 @@ async def test_fanv2_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "on" -async def test_v2_turn_on(hass: HomeAssistant, utcnow) -> None: +async def test_v2_turn_on(hass: HomeAssistant) -> None: """Test that we can turn a fan on.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -472,7 +472,7 @@ async def test_v2_turn_on(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_turn_off(hass: HomeAssistant, utcnow) -> None: +async def test_v2_turn_off(hass: HomeAssistant) -> None: """Test that we can turn a fan off.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -492,7 +492,7 @@ async def test_v2_turn_off(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_set_speed(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_speed(hass: HomeAssistant) -> None: """Test that we set fan speed.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -551,7 +551,7 @@ async def test_v2_set_speed(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_set_percentage(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_percentage(hass: HomeAssistant) -> None: """Test that we set fan speed by percentage.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -584,7 +584,7 @@ async def test_v2_set_percentage(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_set_percentage_with_min_step(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_percentage_with_min_step(hass: HomeAssistant) -> None: """Test that we set fan speed by percentage.""" helper = await setup_test_component(hass, create_fanv2_service_with_min_step) @@ -617,7 +617,7 @@ async def test_v2_set_percentage_with_min_step(hass: HomeAssistant, utcnow) -> N ) -async def test_v2_speed_read(hass: HomeAssistant, utcnow) -> None: +async def test_v2_speed_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -656,7 +656,7 @@ async def test_v2_speed_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["percentage"] == 0 -async def test_v2_set_direction(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_direction(hass: HomeAssistant) -> None: """Test that we can set fan spin direction.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -687,7 +687,7 @@ async def test_v2_set_direction(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_direction_read(hass: HomeAssistant, utcnow) -> None: +async def test_v2_direction_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -702,7 +702,7 @@ async def test_v2_direction_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["direction"] == "reverse" -async def test_v2_oscillate(hass: HomeAssistant, utcnow) -> None: +async def test_v2_oscillate(hass: HomeAssistant) -> None: """Test that we can control a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -733,7 +733,7 @@ async def test_v2_oscillate(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_oscillate_read(hass: HomeAssistant, utcnow) -> None: +async def test_v2_oscillate_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -749,7 +749,7 @@ async def test_v2_oscillate_read(hass: HomeAssistant, utcnow) -> None: async def test_v2_set_percentage_non_standard_rotation_range( - hass: HomeAssistant, utcnow + hass: HomeAssistant, ) -> None: """Test that we set fan speed with a non-standard rotation range.""" helper = await setup_test_component( @@ -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 +) -> 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 e412fed087814d..1a1db53d8dd770 100644 --- a/tests/components/homekit_controller/test_humidifier.py +++ b/tests/components/homekit_controller/test_humidifier.py @@ -63,7 +63,7 @@ def create_dehumidifier_service(accessory): return service -async def test_humidifier_active_state(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_active_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit humidifier on and off again.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -86,7 +86,7 @@ async def test_humidifier_active_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_dehumidifier_active_state(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_active_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit dehumidifier on and off again.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -109,7 +109,7 @@ async def test_dehumidifier_active_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_humidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_read_humidity(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -148,7 +148,7 @@ async def test_humidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: assert state.state == "off" -async def test_dehumidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_read_humidity(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -185,7 +185,7 @@ async def test_dehumidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: assert state.attributes["humidity"] == 40 -async def test_humidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_set_humidity(hass: HomeAssistant) -> None: """Test that we can set the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -201,7 +201,7 @@ async def test_humidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: ) -async def test_dehumidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_set_humidity(hass: HomeAssistant) -> None: """Test that we can set the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -217,7 +217,7 @@ async def test_dehumidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: ) -async def test_humidifier_set_mode(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_set_mode(hass: HomeAssistant) -> None: """Test that we can set the mode of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -250,7 +250,7 @@ async def test_humidifier_set_mode(hass: HomeAssistant, utcnow) -> None: ) -async def test_dehumidifier_set_mode(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_set_mode(hass: HomeAssistant) -> None: """Test that we can set the mode of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -283,7 +283,7 @@ async def test_dehumidifier_set_mode(hass: HomeAssistant, utcnow) -> None: ) -async def test_humidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_read_only_mode(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -323,7 +323,7 @@ async def test_humidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: assert state.attributes["mode"] == "normal" -async def test_dehumidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_read_only_mode(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -363,7 +363,7 @@ async def test_dehumidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: assert state.attributes["mode"] == "normal" -async def test_humidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_target_humidity_modes(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -408,7 +408,7 @@ async def test_humidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> assert state.attributes["humidity"] == 37 -async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -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 +) -> 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 23c6e245ac7bd3..57d206a6025be2 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -17,7 +17,6 @@ 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 @@ -47,7 +46,7 @@ def create_motion_sensor_service(accessory): cur_state.value = 0 -async def test_unload_on_stop(hass: HomeAssistant, utcnow) -> None: +async def test_unload_on_stop(hass: HomeAssistant) -> None: """Test async_unload is called on stop.""" await setup_test_component(hass, create_motion_sensor_service) with patch( @@ -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 get_characteristics(self, chars, *args, **kwargs): @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 d6b36fca22e532..72bf579b36ef99 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -54,7 +54,7 @@ def create_lightbulb_service_with_color_temp(accessory): return service -async def test_switch_change_light_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_light_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit light on and off again.""" helper = await setup_test_component(hass, create_lightbulb_service_with_hs) @@ -85,9 +85,7 @@ async def test_switch_change_light_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_change_light_state_color_temp( - hass: HomeAssistant, utcnow -) -> None: +async def test_switch_change_light_state_color_temp(hass: HomeAssistant) -> None: """Test that we can turn change color_temp.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -107,7 +105,7 @@ async def test_switch_change_light_state_color_temp( ) -async def test_switch_read_light_state_dimmer(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_light_state_dimmer(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service) @@ -142,7 +140,7 @@ async def test_switch_read_light_state_dimmer(hass: HomeAssistant, utcnow) -> No assert state.state == "off" -async def test_switch_push_light_state_dimmer(hass: HomeAssistant, utcnow) -> None: +async def test_switch_push_light_state_dimmer(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service) @@ -170,7 +168,7 @@ async def test_switch_push_light_state_dimmer(hass: HomeAssistant, utcnow) -> No assert state.state == "off" -async def test_switch_read_light_state_hs(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_hs) @@ -208,7 +206,7 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant, utcnow) -> None: assert state.state == "off" -async def test_switch_push_light_state_hs(hass: HomeAssistant, utcnow) -> None: +async def test_switch_push_light_state_hs(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_hs) @@ -239,7 +237,7 @@ async def test_switch_push_light_state_hs(hass: HomeAssistant, utcnow) -> None: assert state.state == "off" -async def test_switch_read_light_state_color_temp(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_light_state_color_temp(hass: HomeAssistant) -> None: """Test that we can read the color_temp of a light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -267,7 +265,7 @@ async def test_switch_read_light_state_color_temp(hass: HomeAssistant, utcnow) - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 -async def test_switch_push_light_state_color_temp(hass: HomeAssistant, utcnow) -> None: +async def test_switch_push_light_state_color_temp(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -288,9 +286,7 @@ async def test_switch_push_light_state_color_temp(hass: HomeAssistant, utcnow) - assert state.attributes["color_temp"] == 400 -async def test_light_becomes_unavailable_but_recovers( - hass: HomeAssistant, utcnow -) -> None: +async def test_light_becomes_unavailable_but_recovers(hass: HomeAssistant) -> None: """Test transition to and from unavailable state.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -318,7 +314,7 @@ async def test_light_becomes_unavailable_but_recovers( assert state.attributes["color_temp"] == 400 -async def test_light_unloaded_removed(hass: HomeAssistant, utcnow) -> None: +async def test_light_unloaded_removed(hass: HomeAssistant) -> None: """Test entity and HKDevice are correctly unloaded and removed.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -343,9 +339,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 +) -> 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 +357,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 +) -> 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 20a18d1acbea6d..9aacda81683942 100644 --- a/tests/components/homekit_controller/test_lock.py +++ b/tests/components/homekit_controller/test_lock.py @@ -28,7 +28,7 @@ def create_lock_service(accessory): return service -async def test_switch_change_lock_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_lock_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit lock on and off again.""" helper = await setup_test_component(hass, create_lock_service) @@ -53,7 +53,7 @@ async def test_switch_change_lock_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_read_lock_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_lock_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit lock accessory.""" helper = await setup_test_component(hass, create_lock_service) @@ -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 +) -> 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 140b722d3aba29..1573fccea02203 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -61,7 +61,7 @@ def create_tv_service_with_target_media_state(accessory): return service -async def test_tv_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_tv_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit fan accessory.""" helper = await setup_test_component(hass, create_tv_service) @@ -90,7 +90,7 @@ async def test_tv_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "idle" -async def test_tv_read_sources(hass: HomeAssistant, utcnow) -> None: +async def test_tv_read_sources(hass: HomeAssistant) -> None: """Test that we can read the input source of a HomeKit TV.""" helper = await setup_test_component(hass, create_tv_service) @@ -99,7 +99,7 @@ async def test_tv_read_sources(hass: HomeAssistant, utcnow) -> None: assert state.attributes["source_list"] == ["HDMI 1", "HDMI 2"] -async def test_play_remote_key(hass: HomeAssistant, utcnow) -> None: +async def test_play_remote_key(hass: HomeAssistant) -> None: """Test that we can play media on a media player.""" helper = await setup_test_component(hass, create_tv_service) @@ -146,7 +146,7 @@ async def test_play_remote_key(hass: HomeAssistant, utcnow) -> None: ) -async def test_pause_remote_key(hass: HomeAssistant, utcnow) -> None: +async def test_pause_remote_key(hass: HomeAssistant) -> None: """Test that we can pause a media player.""" helper = await setup_test_component(hass, create_tv_service) @@ -193,7 +193,7 @@ async def test_pause_remote_key(hass: HomeAssistant, utcnow) -> None: ) -async def test_play(hass: HomeAssistant, utcnow) -> None: +async def test_play(hass: HomeAssistant) -> None: """Test that we can play media on a media player.""" helper = await setup_test_component(hass, create_tv_service_with_target_media_state) @@ -242,7 +242,7 @@ async def test_play(hass: HomeAssistant, utcnow) -> None: ) -async def test_pause(hass: HomeAssistant, utcnow) -> None: +async def test_pause(hass: HomeAssistant) -> None: """Test that we can turn pause a media player.""" helper = await setup_test_component(hass, create_tv_service_with_target_media_state) @@ -290,7 +290,7 @@ async def test_pause(hass: HomeAssistant, utcnow) -> None: ) -async def test_stop(hass: HomeAssistant, utcnow) -> None: +async def test_stop(hass: HomeAssistant) -> None: """Test that we can stop a media player.""" helper = await setup_test_component(hass, create_tv_service_with_target_media_state) @@ -331,7 +331,7 @@ async def test_stop(hass: HomeAssistant, utcnow) -> None: ) -async def test_tv_set_source(hass: HomeAssistant, utcnow) -> None: +async def test_tv_set_source(hass: HomeAssistant) -> None: """Test that we can set the input source of a HomeKit TV.""" helper = await setup_test_component(hass, create_tv_service) @@ -352,7 +352,7 @@ async def test_tv_set_source(hass: HomeAssistant, utcnow) -> None: assert state.attributes["source"] == "HDMI 2" -async def test_tv_set_source_fail(hass: HomeAssistant, utcnow) -> None: +async def test_tv_set_source_fail(hass: HomeAssistant) -> None: """Test that we can set the input source of a HomeKit TV.""" helper = await setup_test_component(hass, create_tv_service) @@ -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 +) -> 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 a95239c23df9fe..d35df281eaba5a 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 +) -> 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", @@ -47,7 +48,7 @@ async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_number(hass: HomeAssistant, utcnow) -> None: +async def test_read_number(hass: HomeAssistant) -> None: """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_spray_level) @@ -73,7 +74,7 @@ async def test_read_number(hass: HomeAssistant, utcnow) -> None: assert state.state == "5" -async def test_write_number(hass: HomeAssistant, utcnow) -> None: +async def test_write_number(hass: HomeAssistant) -> None: """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_spray_level) diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index 9cfa0bccda3aea..baae2cf821967b 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 +) -> 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", @@ -52,7 +53,7 @@ async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_current_mode(hass: HomeAssistant, utcnow) -> None: +async def test_read_current_mode(hass: HomeAssistant) -> None: """Test that Ecobee mode can be correctly read and show as human readable text.""" helper = await setup_test_component(hass, create_service_with_ecobee_mode) @@ -90,7 +91,7 @@ async def test_read_current_mode(hass: HomeAssistant, utcnow) -> None: assert state.state == "away" -async def test_write_current_mode(hass: HomeAssistant, utcnow) -> None: +async def test_write_current_mode(hass: HomeAssistant) -> None: """Test can set a specific mode.""" helper = await setup_test_component(hass, create_service_with_ecobee_mode) helper.accessory.services.first(service_type=ServicesTypes.THERMOSTAT) @@ -138,7 +139,7 @@ async def test_write_current_mode(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_select(hass: HomeAssistant, utcnow) -> None: +async def test_read_select(hass: HomeAssistant) -> None: """Test the generic select can read the current value.""" helper = await setup_test_component(hass, create_service_with_temperature_units) @@ -168,7 +169,7 @@ async def test_read_select(hass: HomeAssistant, utcnow) -> None: assert state.state == "fahrenheit" -async def test_write_select(hass: HomeAssistant, utcnow) -> None: +async def test_write_select(hass: HomeAssistant) -> None: """Test can set a value.""" helper = await setup_test_component(hass, create_service_with_temperature_units) helper.accessory.services.first(service_type=ServicesTypes.THERMOSTAT) diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 829fe8e3cdc65f..3134605125e905 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -69,7 +69,7 @@ def create_battery_level_sensor(accessory): return service -async def test_temperature_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_temperature_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit temperature sensor accessory.""" helper = await setup_test_component( hass, create_temperature_sensor_service, suffix="temperature" @@ -95,7 +95,7 @@ async def test_temperature_sensor_read_state(hass: HomeAssistant, utcnow) -> Non assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT -async def test_temperature_sensor_not_added_twice(hass: HomeAssistant, utcnow) -> None: +async def test_temperature_sensor_not_added_twice(hass: HomeAssistant) -> None: """A standalone temperature sensor should not get a characteristic AND a service entity.""" helper = await setup_test_component( hass, create_temperature_sensor_service, suffix="temperature" @@ -109,7 +109,7 @@ async def test_temperature_sensor_not_added_twice(hass: HomeAssistant, utcnow) - assert created_sensors == {helper.entity_id} -async def test_humidity_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_humidity_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit humidity sensor accessory.""" helper = await setup_test_component( hass, create_humidity_sensor_service, suffix="humidity" @@ -134,7 +134,7 @@ async def test_humidity_sensor_read_state(hass: HomeAssistant, utcnow) -> None: assert state.attributes["device_class"] == SensorDeviceClass.HUMIDITY -async def test_light_level_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_light_level_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit temperature sensor accessory.""" helper = await setup_test_component( hass, create_light_level_sensor_service, suffix="light_level" @@ -159,9 +159,7 @@ async def test_light_level_sensor_read_state(hass: HomeAssistant, utcnow) -> Non assert state.attributes["device_class"] == SensorDeviceClass.ILLUMINANCE -async def test_carbon_dioxide_level_sensor_read_state( - hass: HomeAssistant, utcnow -) -> None: +async def test_carbon_dioxide_level_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit carbon dioxide sensor accessory.""" helper = await setup_test_component( hass, create_carbon_dioxide_level_sensor_service, suffix="carbon_dioxide" @@ -184,7 +182,7 @@ async def test_carbon_dioxide_level_sensor_read_state( assert state.state == "20" -async def test_battery_level_sensor(hass: HomeAssistant, utcnow) -> None: +async def test_battery_level_sensor(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit battery level sensor.""" helper = await setup_test_component( hass, create_battery_level_sensor, suffix="battery" @@ -211,7 +209,7 @@ async def test_battery_level_sensor(hass: HomeAssistant, utcnow) -> None: assert state.attributes["device_class"] == SensorDeviceClass.BATTERY -async def test_battery_charging(hass: HomeAssistant, utcnow) -> None: +async def test_battery_charging(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit battery's charging state.""" helper = await setup_test_component( hass, create_battery_level_sensor, suffix="battery" @@ -235,7 +233,7 @@ async def test_battery_charging(hass: HomeAssistant, utcnow) -> None: assert state.attributes["icon"] == "mdi:battery-charging-20" -async def test_battery_low(hass: HomeAssistant, utcnow) -> None: +async def test_battery_low(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit battery's low state.""" helper = await setup_test_component( hass, create_battery_level_sensor, suffix="battery" @@ -277,7 +275,7 @@ def create_switch_with_sensor(accessory): return service -async def test_switch_with_sensor(hass: HomeAssistant, utcnow) -> None: +async def test_switch_with_sensor(hass: HomeAssistant) -> None: """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_sensor) @@ -307,7 +305,7 @@ async def test_switch_with_sensor(hass: HomeAssistant, utcnow) -> None: assert state.state == "50" -async def test_sensor_unavailable(hass: HomeAssistant, utcnow) -> None: +async def test_sensor_unavailable(hass: HomeAssistant) -> None: """Test a sensor becoming unavailable.""" helper = await setup_test_component(hass, create_switch_with_sensor) @@ -384,7 +382,6 @@ def test_thread_status_to_str() -> None: async def test_rssi_sensor( hass: HomeAssistant, - utcnow, entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: @@ -409,12 +406,11 @@ def transport(self): async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, - utcnow, + entity_registry: er.EntityRegistry, 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_storage.py b/tests/components/homekit_controller/test_storage.py index 583640854a697b..afab63983e2020 100644 --- a/tests/components/homekit_controller/test_storage.py +++ b/tests/components/homekit_controller/test_storage.py @@ -71,7 +71,7 @@ def create_lightbulb_service(accessory): async def test_storage_is_updated_on_add( - hass: HomeAssistant, hass_storage: dict[str, Any], utcnow + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test entity map storage is cleaned up on adding an accessory.""" await setup_test_component(hass, create_lightbulb_service) diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py index 34003984557e81..5b6a77b75c909d 100644 --- a/tests/components/homekit_controller/test_switch.py +++ b/tests/components/homekit_controller/test_switch.py @@ -49,7 +49,7 @@ def create_char_switch_service(accessory): on_char.value = False -async def test_switch_change_outlet_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_outlet_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit outlet on and off again.""" helper = await setup_test_component(hass, create_switch_service) @@ -74,7 +74,7 @@ async def test_switch_change_outlet_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_read_outlet_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_outlet_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit outlet accessory.""" helper = await setup_test_component(hass, create_switch_service) @@ -107,7 +107,7 @@ async def test_switch_read_outlet_state(hass: HomeAssistant, utcnow) -> None: assert switch_1.attributes["outlet_in_use"] is True -async def test_valve_change_active_state(hass: HomeAssistant, utcnow) -> None: +async def test_valve_change_active_state(hass: HomeAssistant) -> None: """Test that we can turn a valve on and off again.""" helper = await setup_test_component(hass, create_valve_service) @@ -132,7 +132,7 @@ async def test_valve_change_active_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_valve_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_valve_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a valve accessory.""" helper = await setup_test_component(hass, create_valve_service) @@ -165,7 +165,7 @@ async def test_valve_read_state(hass: HomeAssistant, utcnow) -> None: assert switch_1.attributes["in_use"] is False -async def test_char_switch_change_state(hass: HomeAssistant, utcnow) -> None: +async def test_char_switch_change_state(hass: HomeAssistant) -> None: """Test that we can turn a characteristic on and off again.""" helper = await setup_test_component( hass, create_char_switch_service, suffix="pairing_mode" @@ -198,7 +198,7 @@ async def test_char_switch_change_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_char_switch_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_char_switch_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit characteristic switch.""" helper = await setup_test_component( hass, create_char_switch_service, suffix="pairing_mode" @@ -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 +) -> 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_button.py b/tests/components/homematicip_cloud/test_button.py index c4b83692267415..5135c0ec48aba2 100644 --- a/tests/components/homematicip_cloud/test_button.py +++ b/tests/components/homematicip_cloud/test_button.py @@ -1,5 +1,6 @@ """Tests for HomematicIP Cloud button.""" -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.button.const import SERVICE_PRESS @@ -11,7 +12,7 @@ async def test_hmip_garage_door_controller_button( - hass: HomeAssistant, default_mock_hap_factory + hass: HomeAssistant, freezer: FrozenDateTimeFactory, default_mock_hap_factory ) -> None: """Test HomematicipGarageDoorControllerButton.""" entity_id = "button.garagentor" @@ -28,13 +29,13 @@ async def test_hmip_garage_door_controller_button( assert state.state == STATE_UNKNOWN now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) state = hass.states.get(entity_id) assert state diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index b042e3daa6c3c0..20193d912394f9 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -3,6 +3,7 @@ from homematicip.base.enums import AbsenceType from homematicip.functionalHomes import IndoorClimateHome +import pytest from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -23,6 +24,7 @@ PERMANENT_END_TIME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from .helper import HAPID, async_manipulate_test_data, get_and_check_entity_basics @@ -340,12 +342,13 @@ async def test_hmip_heating_group_cool( assert ha_state.attributes[ATTR_PRESET_MODE] == "none" assert ha_state.attributes[ATTR_PRESET_MODES] == [] - await hass.services.async_call( - "climate", - "set_preset_mode", - {"entity_id": entity_id, "preset_mode": "Cool2"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": entity_id, "preset_mode": "Cool2"}, + blocking=True, + ) assert len(hmip_device.mock_calls) == service_call_counter + 12 # fire_update_event shows that set_active_profile has not been called. diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 24842ab8bebfa0..b1f063615f3a97 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 4569a6fff6b614..0d9509681918e7 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 e778c82928bb07..0c24d9daebe3fe 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -1,6 +1,5 @@ """Fixtures for HomeWizard integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch from homewizard_energy.errors import NotFoundError @@ -11,7 +10,7 @@ from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, get_fixture_path, load_fixture +from tests.common import MockConfigEntry, get_fixture_path, load_json_object_fixture @pytest.fixture @@ -35,22 +34,22 @@ def mock_homewizardenergy( client = homewizard.return_value client.device.return_value = Device.from_dict( - json.loads(load_fixture(f"{device_fixture}/device.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) ) client.data.return_value = Data.from_dict( - json.loads(load_fixture(f"{device_fixture}/data.json", DOMAIN)) + load_json_object_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)) + load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) ) else: client.state.side_effect = NotFoundError if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): client.system.return_value = System.from_dict( - json.loads(load_fixture(f"{device_fixture}/system.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) ) else: client.system.side_effect = NotFoundError 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 00000000000000..d21b4ed2d4a74b --- /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 00000000000000..4972c491859941 --- /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 00000000000000..362491b3519940 --- /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/HWE-SKT/data.json b/tests/components/homewizard/fixtures/HWE-SKT/data.json index 7e64795298256d..f2a465bd40dc21 100644 --- a/tests/components/homewizard/fixtures/HWE-SKT/data.json +++ b/tests/components/homewizard/fixtures/HWE-SKT/data.json @@ -1,46 +1,8 @@ { "wifi_ssid": "My Wi-Fi", "wifi_strength": 94, - "smr_version": null, - "meter_model": null, - "unique_meter_id": null, - "active_tariff": null, - "total_power_import_kwh": null, "total_power_import_t1_kwh": 63.651, - "total_power_import_t2_kwh": null, - "total_power_import_t3_kwh": null, - "total_power_import_t4_kwh": null, - "total_power_export_kwh": null, "total_power_export_t1_kwh": 0, - "total_power_export_t2_kwh": null, - "total_power_export_t3_kwh": null, - "total_power_export_t4_kwh": null, "active_power_w": 1457.277, - "active_power_l1_w": 1457.277, - "active_power_l2_w": null, - "active_power_l3_w": null, - "active_voltage_l1_v": null, - "active_voltage_l2_v": null, - "active_voltage_l3_v": null, - "active_current_l1_a": null, - "active_current_l2_a": null, - "active_current_l3_a": null, - "active_frequency_hz": null, - "voltage_sag_l1_count": null, - "voltage_sag_l2_count": null, - "voltage_sag_l3_count": null, - "voltage_swell_l1_count": null, - "voltage_swell_l2_count": null, - "voltage_swell_l3_count": null, - "any_power_fail_count": null, - "long_power_fail_count": null, - "active_power_average_w": null, - "monthly_power_peak_w": null, - "monthly_power_peak_timestamp": null, - "total_gas_m3": null, - "gas_timestamp": null, - "gas_unique_id": null, - "active_liter_lpm": null, - "total_liter_m3": null, - "external_devices": null + "active_power_l1_w": 1457.277 } diff --git a/tests/components/homewizard/fixtures/HWE-WTR/data.json b/tests/components/homewizard/fixtures/HWE-WTR/data.json index 169528abef4fa8..16097742891e07 100644 --- a/tests/components/homewizard/fixtures/HWE-WTR/data.json +++ b/tests/components/homewizard/fixtures/HWE-WTR/data.json @@ -1,46 +1,6 @@ { "wifi_ssid": "My Wi-Fi", "wifi_strength": 84, - "smr_version": null, - "meter_model": null, - "unique_meter_id": null, - "active_tariff": null, - "total_power_import_kwh": null, - "total_power_import_t1_kwh": null, - "total_power_import_t2_kwh": null, - "total_power_import_t3_kwh": null, - "total_power_import_t4_kwh": null, - "total_power_export_kwh": null, - "total_power_export_t1_kwh": null, - "total_power_export_t2_kwh": null, - "total_power_export_t3_kwh": null, - "total_power_export_t4_kwh": null, - "active_power_w": null, - "active_power_l1_w": null, - "active_power_l2_w": null, - "active_power_l3_w": null, - "active_voltage_l1_v": null, - "active_voltage_l2_v": null, - "active_voltage_l3_v": null, - "active_current_l1_a": null, - "active_current_l2_a": null, - "active_current_l3_a": null, - "active_frequency_hz": null, - "voltage_sag_l1_count": null, - "voltage_sag_l2_count": null, - "voltage_sag_l3_count": null, - "voltage_swell_l1_count": null, - "voltage_swell_l2_count": null, - "voltage_swell_l3_count": null, - "any_power_fail_count": null, - "long_power_fail_count": null, - "active_power_average_w": null, - "monthly_power_peak_w": null, - "monthly_power_peak_timestamp": null, - "total_gas_m3": null, - "gas_timestamp": null, - "gas_unique_id": null, "active_liter_lpm": 0, - "total_liter_m3": 17.014, - "external_devices": null + "total_liter_m3": 17.014 } diff --git a/tests/components/homewizard/fixtures/SDM230/data.json b/tests/components/homewizard/fixtures/SDM230/data.json index e4eb045dff2b8c..64fb253335919b 100644 --- a/tests/components/homewizard/fixtures/SDM230/data.json +++ b/tests/components/homewizard/fixtures/SDM230/data.json @@ -1,46 +1,8 @@ { "wifi_ssid": "My Wi-Fi", "wifi_strength": 92, - "smr_version": null, - "meter_model": null, - "unique_meter_id": null, - "active_tariff": null, - "total_power_import_kwh": 2.705, "total_power_import_t1_kwh": 2.705, - "total_power_import_t2_kwh": null, - "total_power_import_t3_kwh": null, - "total_power_import_t4_kwh": null, - "total_power_export_kwh": 255.551, "total_power_export_t1_kwh": 255.551, - "total_power_export_t2_kwh": null, - "total_power_export_t3_kwh": null, - "total_power_export_t4_kwh": null, "active_power_w": -1058.296, - "active_power_l1_w": -1058.296, - "active_power_l2_w": null, - "active_power_l3_w": null, - "active_voltage_l1_v": null, - "active_voltage_l2_v": null, - "active_voltage_l3_v": null, - "active_current_l1_a": null, - "active_current_l2_a": null, - "active_current_l3_a": null, - "active_frequency_hz": null, - "voltage_sag_l1_count": null, - "voltage_sag_l2_count": null, - "voltage_sag_l3_count": null, - "voltage_swell_l1_count": null, - "voltage_swell_l2_count": null, - "voltage_swell_l3_count": null, - "any_power_fail_count": null, - "long_power_fail_count": null, - "active_power_average_w": null, - "monthly_power_peak_w": null, - "monthly_power_peak_timestamp": null, - "total_gas_m3": null, - "gas_timestamp": null, - "gas_unique_id": null, - "active_liter_lpm": null, - "total_liter_m3": null, - "external_devices": null + "active_power_l1_w": -1058.296 } diff --git a/tests/components/homewizard/fixtures/SDM630/data.json b/tests/components/homewizard/fixtures/SDM630/data.json new file mode 100644 index 00000000000000..ee143220c67e4d --- /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 00000000000000..b8ec1d18fe8029 --- /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 00000000000000..362491b3519940 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM630/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr index b5b7411532e1dd..663d91539911e5 100644 --- a/tests/components/homewizard/snapshots/test_config_flow.ambr +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -12,6 +12,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -21,6 +22,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -52,6 +54,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -61,6 +64,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -92,6 +96,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -101,6 +106,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -128,6 +134,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -137,6 +144,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index a5c3e6ed8baf1b..01094ec26980d9 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -95,12 +95,12 @@ 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, 'smr_version': None, - 'total_energy_export_kwh': 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': 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, @@ -265,7 +265,78 @@ 'serial': '**REDACTED**', }), 'state': None, - 'system': 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**', diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 436abc70ac185d..5c7e71ea9aca43 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -14,7 +14,7 @@ 'entity_id': 'number.device_status_light_brightness', 'last_changed': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_number_entities[HWE-SKT].1 diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 4f1db0ac7514ee..e237edee58eb24 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -3244,7 +3244,3421 @@ 'state': '100', }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_active_water_usage:device-registry] +# 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': , @@ -3272,11 +6686,174 @@ 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '2.03', + '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[HWE-WTR-entity_ids1][sensor.device_active_water_usage:entity-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3290,41 +6867,44 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.device_active_water_usage', + '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': None, - 'original_icon': 'mdi:water', - 'original_name': 'Active water usage', + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', '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', + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_active_water_usage:state] +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Device Active water usage', - 'icon': 'mdi:water', + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', 'state_class': , - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.device_active_water_usage', + 'entity_id': 'sensor.device_active_power_phase_1', 'last_changed': , 'last_updated': , - 'state': '0', + 'state': '-1058.296', }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_total_water_usage:device-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3347,16 +6927,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', + 'model': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '2.03', + 'sw_version': '3.06', 'via_device_id': None, }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_total_water_usage:entity-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3370,7 +6950,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.device_total_water_usage', + 'entity_id': 'sensor.device_total_energy_export', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3378,34 +6958,33 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , - 'original_icon': 'mdi:gauge', - 'original_name': 'Total water usage', + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_liter_m3', - 'unique_id': 'aabbccddeeff_total_liter_m3', - 'unit_of_measurement': , + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_total_water_usage:state] +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'water', - 'friendly_name': 'Device Total water usage', - 'icon': 'mdi:gauge', + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.device_total_water_usage', + 'entity_id': 'sensor.device_total_energy_export', 'last_changed': , 'last_updated': , - 'state': '17.014', + 'state': '255.551', }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_ssid:device-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3428,16 +7007,96 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', + 'model': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '2.03', + '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[HWE-WTR-entity_ids1][sensor.device_wi_fi_ssid:entity-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3468,7 +7127,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_ssid:state] +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', @@ -3481,7 +7140,7 @@ 'state': 'My Wi-Fi', }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_strength:device-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3504,16 +7163,16 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'HWE-WTR', + 'model': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, - 'sw_version': '2.03', + 'sw_version': '3.06', 'via_device_id': None, }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_strength:entity-registry] +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_strength:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3546,7 +7205,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[HWE-WTR-entity_ids1][sensor.device_wi_fi_strength:state] +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_strength:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', @@ -3558,10 +7217,10 @@ 'entity_id': 'sensor.device_wi_fi_strength', 'last_changed': , 'last_updated': , - 'state': '84', + 'state': '92', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3584,7 +7243,7 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, @@ -3593,7 +7252,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3629,7 +7288,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -3641,10 +7300,10 @@ 'entity_id': 'sensor.device_active_power', 'last_changed': , 'last_updated': , - 'state': '-1058.296', + 'state': '-900.194', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power_phase_1:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3667,7 +7326,7 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, @@ -3676,7 +7335,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power_phase_1:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3712,7 +7371,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_active_power_phase_1:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -3727,7 +7386,7 @@ 'state': '-1058.296', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3750,7 +7409,7 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, @@ -3759,13 +7418,13 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_2:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'device_class': None, @@ -3773,41 +7432,44 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_export', + '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_device_class': , 'original_icon': None, - 'original_name': 'Total energy export', + 'original_name': 'Active power phase 2', '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': , + 'translation_key': 'active_power_l2_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_2:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy export', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 2', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.device_total_energy_export', + 'entity_id': 'sensor.device_active_power_phase_2', 'last_changed': , 'last_updated': , - 'state': '255.551', + 'state': '158.102', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export_tariff_1:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3830,7 +7492,7 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, @@ -3839,13 +7501,13 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export_tariff_1:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_3:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'device_class': None, @@ -3853,41 +7515,44 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_export_tariff_1', + '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_device_class': , 'original_icon': None, - 'original_name': 'Total energy export tariff 1', + 'original_name': 'Active power phase 3', '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': , + 'translation_key': 'active_power_l3_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_export_tariff_1:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_3:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Device Total energy export tariff 1', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 3', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'entity_id': 'sensor.device_active_power_phase_3', 'last_changed': , 'last_updated': , - 'state': '255.551', + 'state': '0.0', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3910,7 +7575,7 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, @@ -3919,7 +7584,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3933,7 +7598,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_import', + 'entity_id': 'sensor.device_total_energy_export', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3943,31 +7608,31 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total energy import', + 'original_name': 'Total energy export', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Device Total energy import', + 'friendly_name': 'Device Total energy export', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.device_total_energy_import', + 'entity_id': 'sensor.device_total_energy_export', 'last_changed': , 'last_updated': , - 'state': '2.705', + 'state': '0.523', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import_tariff_1:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -3990,7 +7655,7 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, @@ -3999,7 +7664,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import_tariff_1:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4013,7 +7678,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'entity_id': 'sensor.device_total_energy_import', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4023,31 +7688,31 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total energy import tariff 1', + 'original_name': 'Total energy import', 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t1_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', 'unit_of_measurement': , }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_total_energy_import_tariff_1:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Device Total energy import tariff 1', + 'friendly_name': 'Device Total energy import', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'entity_id': 'sensor.device_total_energy_import', 'last_changed': , 'last_updated': , - 'state': '2.705', + 'state': '0.101', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_ssid:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4070,7 +7735,7 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, @@ -4079,7 +7744,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_ssid:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4110,7 +7775,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_ssid:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', @@ -4123,7 +7788,7 @@ 'state': 'My Wi-Fi', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_strength:device-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -4146,7 +7811,7 @@ }), 'is_new': False, 'manufacturer': 'HomeWizard', - 'model': 'SDM230-wifi', + 'model': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, 'serial_number': None, @@ -4155,7 +7820,7 @@ 'via_device_id': None, }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_strength:entity-registry] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_strength:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4188,7 +7853,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[SDM230-entity_ids2][sensor.device_wi_fi_strength:state] +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_strength:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index d38fab029d30cd..0fb4680a0b1e72 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_switch_entities[switch.device-state_set-power_on-HWE-SKT] +# name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -12,7 +12,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[switch.device-state_set-power_on-HWE-SKT].1 +# name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -43,7 +43,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.device-state_set-power_on-HWE-SKT].2 +# name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -75,7 +75,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-HWE-SKT] +# name: test_switch_entities[HWE-SKT-switch.device_cloud_connection-system_set-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -88,7 +88,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-HWE-SKT].1 +# name: test_switch_entities[HWE-SKT-switch.device_cloud_connection-system_set-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,7 +119,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.device_cloud_connection-system_set-cloud_enabled-HWE-SKT].2 +# name: test_switch_entities[HWE-SKT-switch.device_cloud_connection-system_set-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -151,7 +151,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-HWE-SKT] +# name: test_switch_entities[HWE-SKT-switch.device_switch_lock-state_set-switch_lock] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -164,7 +164,7 @@ 'state': 'off', }) # --- -# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-HWE-SKT].1 +# name: test_switch_entities[HWE-SKT-switch.device_switch_lock-state_set-switch_lock].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -195,7 +195,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[switch.device_switch_lock-state_set-switch_lock-HWE-SKT].2 +# name: test_switch_entities[HWE-SKT-switch.device_switch_lock-state_set-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -227,3 +227,155 @@ '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 a7b7d0917e6669..c25a4ed0f4ed8e 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -17,7 +17,7 @@ ] -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230"]) +@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) async def test_identify_button_entity_not_loaded_when_not_available( hass: HomeAssistant, ) -> None: @@ -58,7 +58,10 @@ async def test_identify_button( # Raise RequestError when identify is called mock_homewizardenergy.identify.side_effect = RequestError() - 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, @@ -73,7 +76,10 @@ async def test_identify_button( # Raise RequestError when identify is called mock_homewizardenergy.identify.side_effect = DisabledError() - 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, diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index ab7432e8dbf81a..5a140fa70c80cb 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -17,6 +17,7 @@ "HWE-SKT", "HWE-WTR", "SDM230", + "SDM630", ], ) async def test_diagnostics( diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 7dab8cfbb06d44..a4893c77f42f4c 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -7,7 +7,9 @@ from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -118,3 +120,104 @@ async def test_load_handles_homewizardenergy_exception( ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_ERROR, ) + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-SKT", + "aabbccddeeff_total_power_import_t1_kwh", + "aabbccddeeff_total_power_import_kwh", + ), + ( + "HWE-SKT", + "aabbccddeeff_total_power_export_t1_kwh", + "aabbccddeeff_total_power_export_kwh", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_sensor_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test total power T1 sensors are migrated.""" + mock_config_entry.add_to_hass(hass) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + assert entity.unique_id == old_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == new_unique_id + assert entity_migrated.previous_unique_id == old_unique_id + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-SKT", + "aabbccddeeff_total_power_import_t1_kwh", + "aabbccddeeff_total_power_import_kwh", + ), + ( + "HWE-SKT", + "aabbccddeeff_total_power_export_t1_kwh", + "aabbccddeeff_total_power_export_kwh", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_sensor_migration_does_not_trigger( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test total power T1 sensors are not migrated when not possible.""" + mock_config_entry.add_to_hass(hass) + + old_entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + new_entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=new_unique_id, + config_entry=mock_config_entry, + ) + + assert old_entity.unique_id == old_unique_id + assert new_entity.unique_id == new_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = entity_registry.async_get(old_entity.entity_id) + assert entity + assert entity.unique_id == old_unique_id + assert entity.previous_unique_id is None + + entity = entity_registry.async_get(new_entity.entity_id) + assert entity + assert entity.unique_id == new_unique_id + assert entity.previous_unique_id is None diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 0062e32e54e423..a54f98899c6488 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -41,7 +41,7 @@ async def test_number_entities( assert snapshot == device_entry # Test unknown handling - assert state.state == "100" + assert state.state == "100.0" mock_homewizardenergy.state.return_value.brightness = None @@ -64,10 +64,13 @@ async def test_number_entities( ) assert len(mock_homewizardenergy.state_set.mock_calls) == 1 - mock_homewizardenergy.state_set.assert_called_with(brightness=127) + mock_homewizardenergy.state_set.assert_called_with(brightness=129) mock_homewizardenergy.state_set.side_effect = RequestError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", + ): await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, @@ -79,7 +82,10 @@ async def test_number_entities( ) mock_homewizardenergy.state_set.side_effect = DisabledError - with pytest.raises(HomeAssistantError): + 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, @@ -91,7 +97,7 @@ async def test_number_entities( ) -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230"]) +@pytest.mark.parametrize("device_fixture", ["HWE-P1", "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.""" + """Does not load number 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 04795a5e191465..7e59769a76819c 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -69,6 +69,56 @@ "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", [ @@ -84,13 +134,24 @@ "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", "sensor.device_total_energy_import", - "sensor.device_total_energy_import_tariff_1", "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", "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( @@ -139,6 +200,12 @@ async def test_sensors( "sensor.device_total_energy_export_tariff_4", ], ), + ( + "HWE-SKT", + [ + "sensor.device_wi_fi_strength", + ], + ), ( "HWE-WTR", [ @@ -151,6 +218,12 @@ async def test_sensors( "sensor.device_wi_fi_strength", ], ), + ( + "SDM630", + [ + "sensor.device_wi_fi_strength", + ], + ), ], ) async def test_disabled_by_default_sensors( @@ -186,6 +259,46 @@ async def test_sensors_unreachable( @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", [ @@ -250,9 +363,49 @@ async def test_sensors_unreachable( "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", diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 13a0bfaa863c7b..61ca34fab7a96c 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -29,6 +29,13 @@ @pytest.mark.parametrize( ("device_fixture", "entity_ids"), [ + ( + "HWE-P1", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), ( "HWE-WTR", [ @@ -42,7 +49,13 @@ [ "switch.device", "switch.device_switch_lock", - "switch.device_cloud_connection", + ], + ), + ( + "SDM630", + [ + "switch.device", + "switch.device_switch_lock", ], ), ], @@ -56,13 +69,14 @@ async def test_entities_not_created_for_device( assert not hass.states.get(entity_id) -@pytest.mark.parametrize("device_fixture", ["HWE-SKT"]) @pytest.mark.parametrize( - ("entity_id", "method", "parameter"), + ("device_fixture", "entity_id", "method", "parameter"), [ - ("switch.device", "state_set", "power_on"), - ("switch.device_switch_lock", "state_set", "switch_lock"), - ("switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("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( @@ -113,7 +127,10 @@ async def test_switch_entities( # Test request error handling mocked_method.side_effect = RequestError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", + ): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -121,7 +138,10 @@ async def test_switch_entities( blocking=True, ) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", + ): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_OFF, @@ -132,7 +152,10 @@ async def test_switch_entities( # Test disabled error handling mocked_method.side_effect = DisabledError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^The local API of the HomeWizard device is disabled$", + ): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -140,7 +163,10 @@ async def test_switch_entities( blocking=True, ) - with pytest.raises(HomeAssistantError): + 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, diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 45ce862dba8302..9c73e88c3dff99 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 73dda8ed223703..ccfc2c5d264f05 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, @@ -131,30 +132,51 @@ async def test_remove_stale_device( """Test that the stale device is removed.""" location.devices_by_id[another_device.deviceid] = another_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, + config_entry_other = MockConfigEntry( + domain="OtherDomain", + data={}, + unique_id="unique_id", + ) + config_entry_other.add_to_hass(hass) + device_entry_other = device_registry.async_get_or_create( + config_entry_id=config_entry_other.entry_id, identifiers={("OtherDomain", 7654321)}, ) + device_registry.async_update_device( + device_entry_other.id, + add_config_entry_id=config_entry.entry_id, + merge_identifiers={(DOMAIN, 7654321)}, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - assert ( - hass.states.async_entity_ids_count() == 6 - ) # 2 climate entities; 4 sensor entities + assert hass.states.async_entity_ids_count() == 6 - device_registry = dr.async_get(hass) - device_entry = dr.async_entries_for_config_entry( + device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert len(device_entry) == 3 - assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) - assert any((DOMAIN, 7654321) in device.identifiers for device in device_entry) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + + assert len(device_entries) == 2 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries) + assert any((DOMAIN, 7654321) in device.identifiers for device in device_entries) + assert any( + ("OtherDomain", 7654321) in device.identifiers for device in device_entries + ) + assert len(device_entries_other) == 1 assert any( - ("OtherDomain", 7654321) in device.identifiers for device in device_entry + ("OtherDomain", 7654321) in device.identifiers + for device in device_entries_other + ) + assert any( + (DOMAIN, 7654321) in device.identifiers for device in device_entries_other ) assert await config_entry.async_unload(hass) @@ -170,11 +192,21 @@ async def test_remove_stale_device( hass.states.async_entity_ids_count() == 3 ) # 1 climate entities; 2 sensor entities - device_entry = dr.async_entries_for_config_entry( + device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert len(device_entry) == 2 - assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) + assert len(device_entries) == 1 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries) + assert not any((DOMAIN, 7654321) in device.identifiers for device in device_entries) + assert not any( + ("OtherDomain", 7654321) in device.identifiers for device in device_entries + ) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + assert len(device_entries_other) == 1 assert any( - ("OtherDomain", 7654321) in device.identifiers for device in device_entry + ("OtherDomain", 7654321) in device.identifiers + for device in device_entries_other ) diff --git a/tests/components/http/__init__.py b/tests/components/http/__init__.py index 238f5c7050a9a1..cd1d5916ab8f83 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 246572e64f8577..2f1259c22de63d 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -35,9 +35,10 @@ 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 8082a268a80dc1..e38a9c97071319 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -24,9 +24,8 @@ 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" @@ -392,3 +391,29 @@ async def mock_auth(request, handler): resp = await client.get("/auth_false") assert resp.status == HTTPStatus.UNAUTHORIZED assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 + + +async def test_single_ban_file_entry( + hass: HomeAssistant, +) -> None: + """Test that only one item is added to ban file.""" + app = web.Application() + app["hass"] = hass + + async def unauth_handler(request): + """Return a mock web response.""" + raise HTTPUnauthorized + + app.router.add_get("/example", unauth_handler) + setup_bans(hass, app, 2) + mock_real_ip(app)("200.201.202.204") + + manager: IpBanManager = app[KEY_BAN_MANAGER] + m_open = mock_open() + + with patch("homeassistant.components.http.ban.open", m_open, create=True): + remote_ip = ip_address("200.201.202.204") + await manager.async_add_ban(remote_ip) + await manager.async_add_ban(remote_ip) + + assert m_open.call_count == 1 diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 5a5bffe67483b4..97e39811cd8e20 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -5,8 +5,7 @@ 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.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/huawei_lte/__init__.py b/tests/components/huawei_lte/__init__.py index 79602ecfb44dde..2d43a5eade18c3 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 00000000000000..982fba166c3384 --- /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 13307e43648246..e358920b07b9d1 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 @@ CONF_RECIPIENT, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant @@ -25,8 +28,9 @@ 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_select.py b/tests/components/huawei_lte/test_select.py new file mode 100644 index 00000000000000..c3f6ded65b6fdd --- /dev/null +++ b/tests/components/huawei_lte/test_select.py @@ -0,0 +1,43 @@ +"""Tests for the Huawei LTE selects.""" +from unittest.mock import MagicMock, patch + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.components.select import SERVICE_SELECT_OPTION +from homeassistant.components.select.const import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client + +from tests.common import MockConfigEntry + +SELECT_NETWORK_MODE = "select.lte_preferred_network_mode" + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client") +async def test_set_net_mode(client, hass: HomeAssistant) -> None: + """Test setting network mode.""" + client.return_value = magic_client({}) + huawei_lte = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: "http://huawei-lte.example.com"} + ) + 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( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: SELECT_NETWORK_MODE, + ATTR_OPTION: NetworkModeEnum.MODE_4G_3G_AUTO.value, + }, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.net.set_net_mode.assert_called_once() + client.return_value.net.set_net_mode.assert_called_with( + LTEBandEnum.ALL, NetworkBandEnum.ALL, NetworkModeEnum.MODE_4G_3G_AUTO.value + ) diff --git a/tests/components/huawei_lte/test_switches.py b/tests/components/huawei_lte/test_switches.py index dee4def95965c6..acaffdbd0ba938 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.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 29b94b17da1ac7..51e0a7dde7a569 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 919f95b6a6631f..c03e04b633d588 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 c32abecbd0b1a5..55b0c19478116b 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 ef51c2a2f89b04..5ca182d17615a7 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 5fa35cec5b4c1d..ad2d11ff6b63dd 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -8,7 +8,10 @@ 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 45e39e94119ee7..b8793c99d6cebb 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -9,7 +9,10 @@ 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/hue/test_services.py b/tests/components/hue/test_services.py index 01b349c7361132..ec1c1154d7571f 100644 --- a/tests/components/hue/test_services.py +++ b/tests/components/hue/test_services.py @@ -49,11 +49,12 @@ async def test_hue_activate_scene(hass: HomeAssistant, mock_api_v1) -> None: """Test successful hue_activate_scene.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) @@ -85,11 +86,12 @@ async def test_hue_activate_scene(hass: HomeAssistant, mock_api_v1) -> None: async def test_hue_activate_scene_transition(hass: HomeAssistant, mock_api_v1) -> None: """Test successful hue_activate_scene with transition.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) @@ -123,11 +125,12 @@ async def test_hue_activate_scene_group_not_found( ) -> None: """Test failed hue_activate_scene due to missing group.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) @@ -156,11 +159,12 @@ async def test_hue_activate_scene_scene_not_found( ) -> None: """Test failed hue_activate_scene due to missing scene.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index a80f3956f203a9..24cf4b6d962939 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -1,9 +1,20 @@ """The tests for the humidifier component.""" +from enum import Enum +from types import ModuleType from unittest.mock import MagicMock -from homeassistant.components.humidifier import HumidifierEntity +import pytest + +from homeassistant.components import humidifier +from homeassistant.components.humidifier import ( + ATTR_MODE, + HumidifierEntity, + HumidifierEntityFeature, +) from homeassistant.core import HomeAssistant +from tests.common import import_and_test_deprecated_constant_enum + class MockHumidifierEntity(HumidifierEntity): """Mock Humidifier device to use in tests.""" @@ -34,3 +45,52 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: await humidifier.async_turn_off() assert humidifier.turn_off.called + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(humidifier.HumidifierEntityFeature, "SUPPORT_") + + _create_tuples(humidifier.HumidifierDeviceClass, "DEVICE_CLASS_"), +) +@pytest.mark.parametrize(("module"), [humidifier, humidifier.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2025.1" + ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockHumidifierEntity(HumidifierEntity): + _attr_mode = "mode1" + + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockHumidifierEntity() + assert entity.supported_features_compat is HumidifierEntityFeature(1) + assert "MockHumidifierEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "HumidifierEntityFeature.MODES" in caplog.text + caplog.clear() + assert entity.supported_features_compat is HumidifierEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text + + assert entity.state_attributes[ATTR_MODE] == "mode1" diff --git a/tests/components/humidifier/test_significant_change.py b/tests/components/humidifier/test_significant_change.py new file mode 100644 index 00000000000000..3d1b2a7e1abed9 --- /dev/null +++ b/tests/components/humidifier/test_significant_change.py @@ -0,0 +1,53 @@ +"""Test the Humidifier significant change platform.""" +import pytest + +from homeassistant.components.humidifier import ( + ATTR_ACTION, + ATTR_CURRENT_HUMIDITY, + ATTR_HUMIDITY, + ATTR_MODE, +) +from homeassistant.components.humidifier.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Humidifier significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_ACTION: "old_value"}, {ATTR_ACTION: "old_value"}, False), + ({ATTR_ACTION: "old_value"}, {ATTR_ACTION: "new_value"}, True), + ({ATTR_MODE: "old_value"}, {ATTR_MODE: "new_value"}, True), + # multiple attributes + ( + {ATTR_ACTION: "old_value", ATTR_MODE: "old_value"}, + {ATTR_ACTION: "new_value", ATTR_MODE: "old_value"}, + True, + ), + # float attributes + ({ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 61}, True), + ({ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 60.9}, False), + ({ATTR_CURRENT_HUMIDITY: "invalid"}, {ATTR_CURRENT_HUMIDITY: 60.0}, True), + ({ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: "invalid"}, False), + ({ATTR_HUMIDITY: 62.0}, {ATTR_HUMIDITY: 63.0}, True), + ({ATTR_HUMIDITY: 62.0}, {ATTR_HUMIDITY: 62.9}, False), + # insignificant attributes + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Humidifier significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 4a6c8372e57081..8e22fbe84f7b2c 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 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 mock_zones() -> list[dict[str, Any]]: - """Mock Hydrawise zones.""" +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 c60f4392f1e212..f470275813631a 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 c9efbea507e398..17c3eda1699486 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 @@ 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 05c402faca779e..9d98f2a7b442de 100644 --- a/tests/components/hydrawise/test_device.py +++ b/tests/components/hydrawise/test_device.py @@ -9,10 +9,12 @@ 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 79cea94d479bc1..6b41867b04455d 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 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 c6d3fecab65f1d..f0edb79b349317 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 39d789f4cf973d..f044d3467cd907 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 @@ 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.total_seconds() + ) + 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 a6234f34593c9c..e087b0fc1a5e5e 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 6c4cc4e512e4cd..01cc1c7d9d247d 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 dcdd86f0902730..79b9454e29f387 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 7b61b42c9d2003..646e9e4da86f45 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 2e3aafb4984ce6..b29cc3a4b2e636 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 d6c2ba5ad6b12b..8159039aff4c0d 100644 --- a/tests/components/idasen_desk/conftest.py +++ b/tests/components/idasen_desk/conftest.py @@ -55,6 +55,7 @@ async def mock_move_down(): 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_cover.py b/tests/components/idasen_desk/test_cover.py index a9c74be7081ae5..4c8bf7806e086a 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 @@ 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 00000000000000..23d7ac2447bab6 --- /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/ign_sismologia/test_geo_location.py b/tests/components/ign_sismologia/test_geo_location.py index 02a11b3fe7aba5..d1e4eb3d115c66 100644 --- a/tests/components/ign_sismologia/test_geo_location.py +++ b/tests/components/ign_sismologia/test_geo_location.py @@ -2,6 +2,8 @@ import datetime from unittest.mock import MagicMock, call, patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.ign_sismologia.geo_location import ( @@ -71,7 +73,7 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -93,11 +95,10 @@ async def test_setup(hass: HomeAssistant) -> None: ) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (38.3, -3.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "georss_ign_sismologia_client.IgnSismologiaFeed" - ) as mock_feed: + freezer.move_to(utcnow) + + with patch("georss_ign_sismologia_client.IgnSismologiaFeed") as mock_feed: mock_feed.return_value.update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], diff --git a/tests/components/image_upload/test_init.py b/tests/components/image_upload/test_init.py index 486f98e92c2349..9f842d25b645eb 100644 --- a/tests/components/image_upload/test_init.py +++ b/tests/components/image_upload/test_init.py @@ -4,6 +4,7 @@ from unittest.mock import patch from aiohttp import ClientSession, ClientWebSocketResponse +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.websocket_api import const as ws_const from homeassistant.core import HomeAssistant @@ -17,15 +18,17 @@ async def test_upload_image( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, ) -> None: """Test we can upload an image.""" now = dt_util.utcnow() + freezer.move_to(now) with tempfile.TemporaryDirectory() as tempdir, patch.object( hass.config, "path", return_value=tempdir - ), patch("homeassistant.util.dt.utcnow", return_value=now): + ): assert await async_setup_component(hass, "image_upload", {}) ws_client: ClientWebSocketResponse = await hass_ws_client() client: ClientSession = await hass_client() diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index ec864fd4665ab0..713261936c7524 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -18,16 +18,25 @@ b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" ) TEST_MESSAGE_HEADERS2 = ( - b"MIME-Version: 1.0\r\n" b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" - b"Message-ID: " + b"Message-ID: \r\n" + b"MIME-Version: 1.0\r\n" +) + +TEST_MULTIPART_HEADER = ( + b'Content-Type: multipart/related;\r\n\tboundary="Mark=_100584970350292485166"' ) TEST_MESSAGE_HEADERS3 = b"" TEST_MESSAGE = TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 + +TEST_MESSAGE_MULTIPART = ( + TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 + TEST_MULTIPART_HEADER +) + TEST_MESSAGE_NO_SUBJECT_TO_FROM = ( TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS3 ) @@ -44,21 +53,27 @@ TEST_CONTENT_TEXT_BARE = b"\r\nTest body\r\n\r\n" -TEST_CONTENT_BINARY = ( - b"Content-Type: application/binary\r\n" - b"Content-Transfer-Encoding: base64\r\n" - b"\r\n" - b"VGVzdCBib2R5\r\n" -) +TEST_CONTENT_BINARY = b"Content-Type: application/binary\r\n\r\nTest body\r\n" TEST_CONTENT_TEXT_PLAIN = ( - b"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n" - b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n" +) + +TEST_CONTENT_TEXT_BASE64 = ( + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: base64\r\n\r\nVGVzdCBib2R5\r\n" +) + +TEST_CONTENT_TEXT_BASE64_INVALID = ( + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: base64\r\n\r\nVGVzdCBib2R5invalid\r\n" ) +TEST_BADLY_ENCODED_CONTENT = "VGVzdCBib2R5invalid\r\n" TEST_CONTENT_TEXT_OTHER = ( b"Content-Type: text/other; charset=UTF-8\r\n" - b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n" ) TEST_CONTENT_HTML = ( @@ -76,14 +91,40 @@ b"\r\n" b"\r\n" ) +TEST_CONTENT_HTML_BASE64 = ( + b"Content-Type: text/html; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: base64\r\n\r\n" + b"PGh0bWw+CiAgICA8aGVhZD48bWV0YSBodHRwLWVxdW" + b"l2PSJjb250ZW50LXR5cGUiIGNvbnRlbnQ9InRleHQvaHRtbDsgY2hhcnNldD1VVEYtOCI+PC9oZWFkPgog" + b"CAgPGJvZHk+CiAgICAgIDxwPlRlc3QgYm9keTxicj48L3A+CiAgICA8L2JvZHk+CjwvaHRtbD4=\r\n" +) + TEST_CONTENT_MULTIPART = ( b"\r\nThis is a multi-part message in MIME format.\r\n" - + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + TEST_CONTENT_TEXT_PLAIN - + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + TEST_CONTENT_HTML - + b"--------------McwBciN2C0o3rWeF1tmFo2oI--\r\n" + + b"\r\n--Mark=_100584970350292485166--\r\n" +) + +TEST_CONTENT_MULTIPART_BASE64 = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_TEXT_BASE64 + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_HTML_BASE64 + + b"\r\n--Mark=_100584970350292485166--\r\n" +) + +TEST_CONTENT_MULTIPART_BASE64_INVALID = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_TEXT_BASE64_INVALID + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_HTML_BASE64 + + b"\r\n--Mark=_100584970350292485166--\r\n" ) EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) @@ -202,14 +243,40 @@ "OK", [ b"1 FETCH (BODY[] {" - + str(len(TEST_MESSAGE + TEST_CONTENT_MULTIPART)).encode("utf-8") + + str(len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) +TEST_FETCH_RESPONSE_MULTIPART_BASE64 = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64)).encode( + "utf-8" + ) + b"}", - bytearray(TEST_MESSAGE + TEST_CONTENT_MULTIPART), + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64), b")", b"Fetch completed (0.0001 + 0.000 secs).", ], ) +TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str( + len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64_INVALID) + ).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64_INVALID), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM = ( "OK", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index ceda841202c1a4..a00f9d9c25dfe2 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -17,12 +17,15 @@ from .const import ( BAD_RESPONSE, EMPTY_SEARCH_RESPONSE, + TEST_BADLY_ENCODED_CONTENT, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, TEST_FETCH_RESPONSE_INVALID_DATE1, TEST_FETCH_RESPONSE_INVALID_DATE2, TEST_FETCH_RESPONSE_INVALID_DATE3, TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_MULTIPART_BASE64, + TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, @@ -110,6 +113,7 @@ async def test_entry_startup_fails( (TEST_FETCH_RESPONSE_TEXT_OTHER, True), (TEST_FETCH_RESPONSE_HTML, True), (TEST_FETCH_RESPONSE_MULTIPART, True), + (TEST_FETCH_RESPONSE_MULTIPART_BASE64, True), (TEST_FETCH_RESPONSE_BINARY, True), ], ids=[ @@ -122,6 +126,7 @@ async def test_entry_startup_fails( "other", "html", "multipart", + "multipart_base64", "binary", ], ) @@ -154,7 +159,7 @@ async def test_receiving_message_successfully( assert data["folder"] == "INBOX" assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" - assert data["text"] + assert "Test body" in data["text"] assert ( valid_date and isinstance(data["date"], datetime) @@ -163,6 +168,48 @@ async def test_receiving_message_successfully( ) +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + ("imap_fetch"), + [ + TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, + ], + ids=[ + "multipart_base64_invalid", + ], +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_receiving_message_with_invalid_encoding( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test receiving a message successfully.""" + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] == TEST_BADLY_ENCODED_CONTENT + + @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize("imap_fetch", [TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM]) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @@ -196,7 +243,7 @@ async def test_receiving_message_no_subject_to_from( assert data["date"] == datetime( 2023, 3, 24, 13, 52, tzinfo=timezone(timedelta(seconds=3600)) ) - assert data["text"] == "Test body\r\n\r\n" + assert data["text"] == "Test body\r\n" assert data["headers"]["Return-Path"] == ("",) assert data["headers"]["Delivered-To"] == ("notify@example.com",) diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index f0c77c9bce3a06..e333071b0bdabc 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -302,7 +302,7 @@ async def _test_common_success_w_authorize( """Test bluetooth and user flow success paths.""" async def subscribe_state_updates( - state_callback: Callable[[State], None] + state_callback: Callable[[State], None], ) -> Callable[[], None]: state_callback(State.AUTHORIZED) return lambda: None @@ -612,7 +612,7 @@ async def test_provision_not_authorized(hass: HomeAssistant, exc, error) -> None """Test bluetooth flow with error.""" async def subscribe_state_updates( - state_callback: Callable[[State], None] + state_callback: Callable[[State], None], ) -> Callable[[], None]: state_callback(State.AUTHORIZED) return lambda: None diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index 654518560020aa..4caf914ca19a49 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 f3b4eef36f5b28..9233668c113f8f 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 940d0ff6c55a46..a0b80ac420c02e 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 3703ca39cd56b2..1334ba4aebd113 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 6908a1c532e2dd..3978d0cf175020 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 ea12eabd04f248..23d1c3307e5c35 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 e731c51d6c651e..53db12acb04f7a 100644 --- a/tests/components/insteon/const.py +++ b/tests/components/insteon/const.py @@ -38,6 +38,10 @@ 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 ce061e47c3dcc2..7485914026a24b 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 e15b7b2a287825..106c93071bee1b 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 @@ STEP_HUB_V1, STEP_HUB_V2, STEP_PLM, + STEP_PLM_MANUALLY, STEP_REMOVE_OVERRIDE, STEP_REMOVE_X10, ) @@ -45,6 +45,7 @@ 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 15f529babd84b8..f772eed2d26ea0 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 42a6d511b7eb9b..c100acae3ce37c 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 b68e3cdb1eb540..885c10277f885b 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -11,11 +11,11 @@ @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 0c2744dd6547ca..8ef9caf4928566 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 6e4e00202c84b1..d80add2a4415f5 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -119,15 +119,15 @@ 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) -> None: +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 - entity_registry = er.async_get(hass) - cover = entity_registry.async_get_or_create("cover", "test", "cover_uid") lock = entity_registry.async_get_or_create("lock", "test", "lock_uid") diff --git a/tests/components/ipma/snapshots/test_weather.ambr b/tests/components/ipma/snapshots/test_weather.ambr index 92e1d1a91b58a2..0a778776329930 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 71884e0c82e583..9e0262733a37b9 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -22,7 +22,8 @@ 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 5992b928f630d4..cbcad90389866a 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 075d7249d36202..b24d473c7df8ca 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/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr index 49006716fb3c3b..c46a2cc15e39be 100644 --- a/tests/components/iqvia/snapshots/test_diagnostics.ambr +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -350,6 +350,7 @@ 'disabled_by': None, 'domain': 'iqvia', 'entry_id': '690ac4b7e99855fc5ee7b987a758d5cb', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index b93c46108d8326..4df733a93fc1c1 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -2,46 +2,34 @@ from datetime import datetime +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME 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", +MOCK_USER_INPUT = { + CONF_NAME: "Home", + CONF_LOCATION: {CONF_LATITUDE: 12.34, CONF_LONGITUDE: 23.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), -} +MOCK_CONFIG = {CONF_LATITUDE: 12.34, CONF_LONGITUDE: 23.45} -NEW_PRAYER_TIMES = { - "Fajr": "06:00", - "Sunrise": "07:25", - "Dhuhr": "12:30", - "Asr": "15:32", - "Maghrib": "17:45", - "Isha": "18:53", - "Midnight": "00:43", +PRAYER_TIMES = { + "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_TIMESTAMPS = { - "Fajr": datetime(2020, 1, 2, 6, 00, 0, tzinfo=dt_util.UTC), - "Sunrise": datetime(2020, 1, 2, 7, 25, 0, tzinfo=dt_util.UTC), - "Dhuhr": datetime(2020, 1, 2, 12, 30, 0, tzinfo=dt_util.UTC), - "Asr": datetime(2020, 1, 2, 15, 32, 0, tzinfo=dt_util.UTC), - "Maghrib": datetime(2020, 1, 2, 17, 45, 0, tzinfo=dt_util.UTC), - "Isha": datetime(2020, 1, 2, 18, 53, 0, tzinfo=dt_util.UTC), - "Midnight": datetime(2020, 1, 2, 00, 43, 0, tzinfo=dt_util.UTC), +NEW_PRAYER_TIMES = { + "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_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index f331c5bf49b657..0375c788b11519 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -1,5 +1,9 @@ """Tests for Islamic Prayer Times config flow.""" +from unittest.mock import patch + +from prayer_times_calculator import InvalidResponseError import pytest +from requests.exceptions import ConnectionError as ConnError from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times @@ -12,6 +16,8 @@ ) from homeassistant.core import HomeAssistant +from . import MOCK_CONFIG, MOCK_USER_INPUT + from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -25,13 +31,47 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - await hass.async_block_till_done() + with patch( + "homeassistant.components.islamic_prayer_times.config_flow.async_validate_location", + return_value={}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Islamic Prayer Times" + assert result["title"] == "Home" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidResponseError, "invalid_location"), + (ConnError, "conn_error"), + ], +) +async def test_flow_error( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test flow errors.""" + result = await hass.config_entries.flow.async_init( + islamic_prayer_times.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.islamic_prayer_times.config_flow.PrayerTimesCalculator.fetch_prayer_times", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"]["base"] == error async def test_options(hass: HomeAssistant) -> None: @@ -39,7 +79,7 @@ async def test_options(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, title="Islamic Prayer Times", - data={}, + data=MOCK_CONFIG, options={CONF_CALC_METHOD: "isna"}, ) entry.add_to_hass(hass) @@ -68,14 +108,19 @@ async def test_options(hass: HomeAssistant) -> None: async def test_integration_already_configured(hass: HomeAssistant) -> None: """Test integration is already configured.""" entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={}, + domain=DOMAIN, data=MOCK_CONFIG, options={}, unique_id="12.34-23.45" ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( islamic_prayer_times.DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index a1fcf32efba69c..746abf27d43a7e 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -10,11 +10,12 @@ 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.const import CONF_LATITUDE, CONF_LONGITUDE, 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, NOW, PRAYER_TIMES, PRAYER_TIMES_TIMESTAMPS +from . import NEW_PRAYER_TIMES, NOW, PRAYER_TIMES from tests.common import MockConfigEntry, async_fire_time_changed @@ -90,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 @@ -123,7 +124,9 @@ async def test_update_failed(hass: HomeAssistant) -> None: InvalidResponseError, NEW_PRAYER_TIMES, ] - future = PRAYER_TIMES_TIMESTAMPS["Midnight"] + timedelta(days=1, minutes=1) + 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() @@ -154,15 +157,16 @@ async def test_update_failed(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, @@ -178,6 +182,28 @@ 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}" + + +async def test_migration_from_1_1_to_1_2(hass: HomeAssistant) -> None: + """Test migrating from version 1.1 to 1.2.""" + entry = MockConfigEntry( + domain=islamic_prayer_times.DOMAIN, + data={}, + ) + entry.add_to_hass(hass) + + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ), freeze_time(NOW): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.data == { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + } + assert entry.minor_version == 2 diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index e7f3759f993013..164ac8818fe502 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -6,9 +6,8 @@ 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 64ed41ffdfa890..00fe230b31fa40 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -41,14 +41,13 @@ 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 087be30b70c942..733cb795271003 100644 --- a/tests/components/jellyfin/test_sensor.py +++ b/tests/components/jellyfin/test_sensor.py @@ -17,13 +17,12 @@ 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 4b40519598f6f9..d14ae0faad2ce0 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -169,6 +169,7 @@ ) 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 1aa7fad00d2be7..0f2912e9de3472 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -496,6 +496,7 @@ async def test_jewish_calendar_sensor( ) 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 0f1ef8b6dcf0de..ef9de41ca3233a 100644 --- a/tests/components/jvc_projector/test_init.py +++ b/tests/components/jvc_projector/test_init.py @@ -16,11 +16,11 @@ 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 5beccd33e38580..5505e160ca73ec 100644 --- a/tests/components/jvc_projector/test_remote.py +++ b/tests/components/jvc_projector/test_remote.py @@ -21,13 +21,14 @@ 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 d0826f4714af76..28d902909964a4 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 f38c61d3e738e2..ad7dcbcaa51746 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 3fbff29e3e9e98..70406872464b70 100644 --- a/tests/components/kaleidescape/test_sensor.py +++ b/tests/components/kaleidescape/test_sensor.py @@ -18,12 +18,13 @@ 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/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index ebd0f781d22513..71f3a83c701537 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -229,6 +229,7 @@ async def test_issues_created( "description_placeholders": None, "flow_id": flow_id, "handler": DOMAIN, + "minor_version": 1, "type": "create_entry", "version": 1, } diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index 47715433a5215b..aace7a0224ca76 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -24,7 +24,7 @@ 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 3e8519feb98323..3dedea7d8d440e 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -4,14 +4,9 @@ import pytest -from homeassistant.components.knx.const import ( - CONF_PAYLOAD, - CONF_PAYLOAD_LENGTH, - DOMAIN, - KNX_ADDRESS, -) +from homeassistant.components.knx.const import CONF_PAYLOAD_LENGTH, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ButtonSchema -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -130,9 +125,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 5d42ed795421c3..0f2d8e56050e22 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/knx/test_select.py b/tests/components/knx/test_select.py index 1c89338920ea46..1b408a298a25ef 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -2,7 +2,6 @@ import pytest from homeassistant.components.knx.const import ( - CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -10,8 +9,9 @@ KNX_ADDRESS, ) from homeassistant.components.knx.schema import SelectSchema -from homeassistant.const import CONF_NAME, STATE_UNKNOWN +from homeassistant.const import CONF_NAME, CONF_PAYLOAD, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ServiceValidationError from .conftest import KNXTestKit @@ -77,7 +77,7 @@ async def test_select_dpt_2_simple(hass: HomeAssistant, knx: KNXTestKit) -> None assert state.state is STATE_UNKNOWN # select invalid option - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( "select", "select_option", diff --git a/tests/components/komfovent/__init__.py b/tests/components/komfovent/__init__.py deleted file mode 100644 index e5492a52327bb6..00000000000000 --- a/tests/components/komfovent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Komfovent integration.""" diff --git a/tests/components/komfovent/test_config_flow.py b/tests/components/komfovent/test_config_flow.py deleted file mode 100644 index 008d92e36a3ff8..00000000000000 --- a/tests/components/komfovent/test_config_flow.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Test the Komfovent config flow.""" -from unittest.mock import AsyncMock, patch - -import komfovent_api -import pytest - -from homeassistant import config_entries -from homeassistant.components.komfovent.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test flow completes as expected.""" - 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 - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_authenticating( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow authentication step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_device_info( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow device info download step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_device_already_exists( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test device is not added when it already exists.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - unique_id="test-uid", - ) - entry.add_to_hass(hass) - - 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 - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.ABORT - assert final_result["reason"] == "already_configured" - - -async def __test_normal_flow(hass: HomeAssistant, flow_id: str) -> FlowResult: - """Test flow completing as expected, no matter what happened before.""" - - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventSettings("test-name", None, None, "test-uid"), - ), - ): - final_result = await hass.config_entries.flow.async_configure( - flow_id, - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - return final_result diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py index 2dff9672f17b85..658f1053f93f16 100644 --- a/tests/components/konnected/test_init.py +++ b/tests/components/konnected/test_init.py @@ -706,7 +706,7 @@ async def test_state_updates_zone( resp = await client.post( "/api/konnected/device/112233445566", headers={"Authorization": "Bearer abcdefgh"}, - json={"zone": "5", "temp": 32, "addr": 1}, + json={"zone": "5", "temp": 32.0, "addr": 1}, ) assert resp.status == HTTPStatus.OK result = await resp.json() @@ -863,7 +863,7 @@ async def test_state_updates_pin( resp = await client.post( "/api/konnected/device/112233445566", headers={"Authorization": "Bearer abcdefgh"}, - json={"pin": "7", "temp": 32, "addr": 1}, + json={"pin": "7", "temp": 32.0, "addr": 1}, ) assert resp.status == HTTPStatus.OK result = await resp.json() diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index 814a46f4a254bb..a83d9fd5e17744 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 41facfe9c2608c..8bfe227bfdf45b 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 d6a576484006fe..d509a323e6a47d 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", ) ] } @@ -45,6 +43,7 @@ async def test_entry_diagnostics( "config_entry": { "entry_id": "2ab8dd92a62787ddfe213a67e09406bd", "version": 1, + "minor_version": 1, "domain": "kostal_plenticore", "title": "scb", "data": {"host": "192.168.1.2", "password": REDACTED}, @@ -56,12 +55,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 61df222fd9e9a7..93550405897dde 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 @@ 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 dd5ba7127a83bd..fc7d9f213fe99f 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -23,9 +23,9 @@ @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 682e8f72ac87a5..9af2589af9b1c8 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 5ef913ab74bbf4..3ba351a4225da1 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/lacrosse_view/__init__.py b/tests/components/lacrosse_view/__init__.py index 66789508f05bd7..913f6c72f24bc5 100644 --- a/tests/components/lacrosse_view/__init__.py +++ b/tests/components/lacrosse_view/__init__.py @@ -70,7 +70,7 @@ sensor_id="2", sensor_field_names=["HeatIndex"], location=Location(id="1", name="Test"), - data={"HeatIndex": {"values": [{"s": 2.3}], "unit": "degrees_celsius"}}, + data={"HeatIndex": {"values": [{"s": 2.3}], "unit": "degrees_fahrenheit"}}, permissions={"read": True}, model="Test", ) @@ -81,7 +81,7 @@ sensor_id="2", sensor_field_names=["WindSpeed"], location=Location(id="1", name="Test"), - data={"WindSpeed": {"values": [{"s": 2}], "unit": "degrees_celsius"}}, + data={"WindSpeed": {"values": [{"s": 2}], "unit": "kilometers_per_hour"}}, permissions={"read": True}, model="Test", ) @@ -107,3 +107,14 @@ permissions={"read": True}, model="Test", ) +TEST_UNITS_OVERRIDE_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"Temperature": {"values": [{"s": "2.1"}], "unit": "degrees_fahrenheit"}}, + permissions={"read": True}, + model="Test", +) diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index 30094f97cd3599..9d880746ff9d5d 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -17,6 +17,7 @@ 'disabled_by': None, 'domain': 'lacrosse_view', 'entry_id': 'lacrosse_view_test_entry_id', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 837d13b8a4bd7e..8fc028e2da17b3 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -19,6 +19,7 @@ TEST_NO_PERMISSION_SENSOR, TEST_SENSOR, TEST_STRING_SENSOR, + TEST_UNITS_OVERRIDE_SENSOR, TEST_UNSUPPORTED_SENSOR, ) @@ -94,6 +95,7 @@ async def test_field_not_supported( (TEST_STRING_SENSOR, "dry", "wet_dry"), (TEST_ALREADY_FLOAT_SENSOR, "-16.5", "heat_index"), (TEST_ALREADY_INT_SENSOR, "2", "wind_speed"), + (TEST_UNITS_OVERRIDE_SENSOR, "-16.6", "temperature"), ], ) async def test_field_types( diff --git a/tests/components/lametric/test_helpers.py b/tests/components/lametric/test_helpers.py index 9a03a4d52cf3e2..a1b824086d2e0f 100644 --- a/tests/components/lametric/test_helpers.py +++ b/tests/components/lametric/test_helpers.py @@ -12,12 +12,11 @@ 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 6a6ff4256a7672..9a1258a82bbf50 100644 --- a/tests/components/lametric/test_services.py +++ b/tests/components/lametric/test_services.py @@ -34,10 +34,10 @@ 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_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index b58c91f8f16a3b..19338d8d57685e 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -104,7 +104,7 @@ async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> No async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: """Test manual entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -135,7 +135,7 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: """Test select from list entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException port = mock_serial_port() result = await hass.config_entries.flow.async_init( diff --git a/tests/components/landisgyr_heat_meter/test_init.py b/tests/components/landisgyr_heat_meter/test_init.py index 46fc07c5eb9d50..f8615aa77af836 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/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 5ed2a397ccd599..f05d12e49a2acd 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -150,7 +150,7 @@ async def test_exception_on_polling( assert state.state == "123.0" # Now 'disable' the connection and wait for polling and see if it fails - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException freezer.tick(POLLING_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index 70df5af2305d21..c92a45d7cc953b 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 74240c900be596..4705591e1d3892 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 47287fbd1d2b90..59cabb309b0367 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 a3b5b01ffbbd52..fb1d09d91d6865 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 73827ad38bb76f..7f23c1e6214b5d 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 116ab62854dd6a..b46de397255ea2 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 44a9e410fe34ad..a83d45c08891ee 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 5d6961e57c3bf2..ce3a8536b2f4cc 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 d71a7eeaf0bf5e..9fa065f3632655 100644 --- a/tests/components/lifx/test_binary_sensor.py +++ b/tests/components/lifx/test_binary_sensor.py @@ -31,7 +31,9 @@ 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 d527229fe784ef..1fd4da4531ee5d 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 1b7da4f864a70b..702841061667a2 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 @@ def __call__(self, callb=None, *args, **kwargs): 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 70a5a89a3ae113..887e622b5ccf24 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 @@ def __call__(self, callb=None, *args, **kwargs): 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 @@ def __call__(self, callb=None, *args, **kwargs): 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 @@ def __call__(self, callb=None, *args, **kwargs): 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 @@ def __call__(self, callb=None, *args, **kwargs): 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 aa705418d554ef..529925be7268c2 100644 --- a/tests/components/lifx/test_select.py +++ b/tests/components/lifx/test_select.py @@ -25,7 +25,9 @@ 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 5fe69c8dabc2a9..e27bc0de3a8dbc 100644 --- a/tests/components/lifx/test_sensor.py +++ b/tests/components/lifx/test_sensor.py @@ -31,7 +31,9 @@ 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_init.py b/tests/components/light/test_init.py index 675057899b02ce..903002063e8c35 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1444,6 +1444,7 @@ async def test_light_service_call_color_conversion( platform.ENTITIES.append(platform.MockLight("Test_legacy", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbw", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbww", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("Test_temperature", STATE_ON)) entity0 = platform.ENTITIES[0] entity0.supported_color_modes = {light.ColorMode.HS} @@ -1470,6 +1471,9 @@ async def test_light_service_call_color_conversion( entity6 = platform.ENTITIES[6] entity6.supported_color_modes = {light.ColorMode.RGBWW} + entity7 = platform.ENTITIES[7] + entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP} + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1498,6 +1502,9 @@ async def test_light_service_call_color_conversion( state = hass.states.get(entity6.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] + state = hass.states.get(entity7.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + await hass.services.async_call( "light", "turn_on", @@ -1510,6 +1517,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 100), @@ -1530,6 +1538,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 1739, "color_temp": 575} await hass.services.async_call( "light", @@ -1543,6 +1553,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 0), @@ -1564,6 +1575,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint of the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1577,6 +1590,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (128, 0, 0), @@ -1597,6 +1611,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 6279, "color_temp": 159} await hass.services.async_call( "light", @@ -1610,6 +1626,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (255, 255, 255), @@ -1631,6 +1648,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1644,6 +1663,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.1, 0.8), @@ -1664,6 +1684,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 8645, "color_temp": 115} await hass.services.async_call( "light", @@ -1677,6 +1699,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.323, 0.329), @@ -1698,6 +1721,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1711,6 +1736,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (128, 0, 0, 64), @@ -1732,6 +1758,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3011, "color_temp": 332} await hass.services.async_call( "light", @@ -1745,6 +1773,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (255, 255, 255, 255), @@ -1766,6 +1795,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1779,6 +1810,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (128, 0, 0, 64, 32), @@ -1799,6 +1831,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3845, "color_temp": 260} await hass.services.async_call( "light", @@ -1812,6 +1846,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (255, 255, 255, 255, 255), @@ -1833,6 +1868,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3451, "color_temp": 289} async def test_light_service_call_color_conversion_named_tuple( @@ -2552,3 +2589,24 @@ def test_filter_supported_color_modes() -> None: # ColorMode.BRIGHTNESS has priority over ColorMode.ONOFF supported = {light.ColorMode.ONOFF, light.ColorMode.BRIGHTNESS} assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS} + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockLightEntityEntity(light.LightEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockLightEntityEntity() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "MockLightEntityEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "LightEntityFeature" in caplog.text + assert "and color modes" in caplog.text + caplog.clear() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index 816bde430e772a..65b83aa0269faa 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 00000000000000..e5abc6c943c5fd --- /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 00000000000000..64664745c546d2 --- /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 00000000000000..fc3087db354c50 --- /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 00000000000000..428411d39e0b44 --- /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 00000000000000..0650196d619c6d --- /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 00000000000000..e8d767700505a5 --- /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 00000000000000..d8348b9bb64a66 --- /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/conftest.py b/tests/components/litejet/conftest.py index 3bbd9ef4ef0d80..2c631265c30f3f 100644 --- a/tests/components/litejet/conftest.py +++ b/tests/components/litejet/conftest.py @@ -1,6 +1,6 @@ """Fixtures for LiteJet testing.""" from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -21,6 +21,12 @@ async def get_scene_name(number): async def get_switch_name(number): return f"Mock Switch #{number}" + def get_switch_keypad_number(number): + return number + 100 + + def get_switch_keypad_name(number): + return f"Mock Keypad #{number + 100}" + mock_lj = mock_pylitejet.return_value mock_lj.switch_pressed_callbacks = {} @@ -65,6 +71,8 @@ def on_connected_changed(callback): mock_lj.get_switch_name = AsyncMock(side_effect=get_switch_name) mock_lj.press_switch = AsyncMock() mock_lj.release_switch = AsyncMock() + mock_lj.get_switch_keypad_number = Mock(side_effect=get_switch_keypad_number) + mock_lj.get_switch_keypad_name = Mock(side_effect=get_switch_keypad_name) mock_lj.scenes.return_value = range(1, 3) mock_lj.get_scene_name = AsyncMock(side_effect=get_scene_name) @@ -74,6 +82,7 @@ def on_connected_changed(callback): mock_lj.start_time = dt_util.utcnow() mock_lj.last_delta = timedelta(0) mock_lj.connected = True + mock_lj.model_name = "MockJet" def connected_changed(connected: bool, reason: str) -> None: mock_lj.connected = connected diff --git a/tests/components/litejet/test_diagnostics.py b/tests/components/litejet/test_diagnostics.py index 368cdf557c8b51..a2c8bc72476c8e 100644 --- a/tests/components/litejet/test_diagnostics.py +++ b/tests/components/litejet/test_diagnostics.py @@ -17,6 +17,7 @@ async def test_diagnostics( diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag == { + "model": "MockJet", "loads": [1, 2], "button_switches": [1, 2], "scenes": [1, 2], diff --git a/tests/components/litejet/test_scene.py b/tests/components/litejet/test_scene.py index d1316d81bbe83b..76c1556f66de41 100644 --- a/tests/components/litejet/test_scene.py +++ b/tests/components/litejet/test_scene.py @@ -17,16 +17,16 @@ 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 a17c0439824f48..9a4145dd224703 100644 --- a/tests/components/litterrobot/test_button.py +++ b/tests/components/litterrobot/test_button.py @@ -13,10 +13,11 @@ 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 170d63130292cf..25c47ee49455a6 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -14,7 +14,6 @@ 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_select.py b/tests/components/litterrobot/test_select.py index f6a32a6ef35f07..b35fdf5c917876 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -12,6 +12,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from .conftest import setup_integration @@ -59,7 +60,7 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No data = {ATTR_ENTITY_ID: SELECT_ENTITY_ID, ATTR_OPTION: "10"} - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( PLATFORM_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 3aee7b5075fe88..fe77119ca5e12a 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -32,21 +32,22 @@ } -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/livisi/__init__.py b/tests/components/livisi/__init__.py index 3d28d1db70801f..48a7e21ad8d1e2 100644 --- a/tests/components/livisi/__init__.py +++ b/tests/components/livisi/__init__.py @@ -1,7 +1,7 @@ """Tests for the LIVISI Smart Home integration.""" from unittest.mock import patch -from homeassistant.components.livisi.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD VALID_CONFIG = { CONF_HOST: "1.1.1.1", diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 39e9264d45a0a6..22d8abade50331 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 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,62 @@ async def get() -> list[dict[str, str]]: @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") + + +EXPECTED_ADD_ITEM = { + "status": "needs_action", + "summary": "replace batteries", +} + + +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ({}, EXPECTED_ADD_ITEM), + ({"due_date": "2023-11-17"}, {**EXPECTED_ADD_ITEM, "due": "2023-11-17"}), + ( + {"due_datetime": "2023-11-17T11:30:00+00:00"}, + {**EXPECTED_ADD_ITEM, "due": "2023-11-17T05:30:00-06:00"}, + ), + ( + {"description": "Additional detail"}, + {**EXPECTED_ADD_ITEM, "description": "Additional detail"}, + ), + ({"description": ""}, {**EXPECTED_ADD_ITEM, "description": ""}), + ({"description": None}, EXPECTED_ADD_ITEM), + ], +) 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,32 +105,47 @@ 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, ) items = await ws_get_items() assert len(items) == 1 - assert items[0]["summary"] == "replace batteries" - assert items[0]["status"] == "needs_action" - assert "uid" in items[0] + item_data = items[0] + assert "uid" in item_data + del item_data["uid"] + assert item_data == expected_item_data state = hass.states.get(TEST_ENTITY) assert state 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 +154,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 +217,40 @@ async def test_bulk_remove( assert state.state == "0" +EXPECTED_UPDATE_ITEM = { + "status": "needs_action", + "summary": "soda", +} + + +@pytest.mark.parametrize( + ("item_data", "expected_item_data", "expected_state"), + [ + ({"status": "completed"}, {**EXPECTED_UPDATE_ITEM, "status": "completed"}, "0"), + ( + {"due_date": "2023-11-17"}, + {**EXPECTED_UPDATE_ITEM, "due": "2023-11-17"}, + "1", + ), + ( + {"due_datetime": "2023-11-17T11:30:00+00:00"}, + {**EXPECTED_UPDATE_ITEM, "due": "2023-11-17T05:30:00-06:00"}, + "1", + ), + ( + {"description": "Additional detail"}, + {**EXPECTED_UPDATE_ITEM, "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.""" @@ -208,6 +266,7 @@ async def test_update_item( # Fetch item items = await ws_get_items() assert len(items) == 1 + item = items[0] assert item["summary"] == "soda" assert item["status"] == "needs_action" @@ -216,25 +275,190 @@ async def test_update_item( assert state assert state.state == "1" - # Mark item completed + # 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" + assert "uid" in item + del item["uid"] + assert item == expected_item_data state = hass.states.get(TEST_ENTITY) assert state - assert state.state == "0" + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ( + {"status": "completed"}, + { + "summary": "soda", + "status": "completed", + "description": "Additional detail", + "due": "2024-01-01", + }, + ), + ( + {"due_date": "2024-01-02"}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + "due": "2024-01-02", + }, + ), + ( + {"due_date": None}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + }, + ), + ( + {"due_datetime": "2024-01-01 10:30:00"}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + "due": "2024-01-01T10:30:00-06:00", + }, + ), + ( + {"due_datetime": None}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + }, + ), + ( + {"description": "updated description"}, + { + "summary": "soda", + "status": "needs_action", + "due": "2024-01-01", + "description": "updated description", + }, + ), + ( + {"description": None}, + {"summary": "soda", "status": "needs_action", "due": "2024-01-01"}, + ), + ], + ids=[ + "status", + "due_date", + "clear_due_date", + "due_datetime", + "clear_due_datetime", + "description", + "clear_description", + ], +) +async def test_update_existing_field( + 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 updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda", "description": "Additional detail", "due_date": "2024-01-01"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + # Perform update + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": item["uid"], **item_data}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is updated + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert "uid" in item + del item["uid"] + assert item == expected_item_data + + +async def test_rename( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test renaming a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Rename item + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": item["uid"], "rename": "water"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item has been renamed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "water" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" @pytest.mark.parametrize( @@ -418,3 +642,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/conftest.py b/tests/components/lock/conftest.py new file mode 100644 index 00000000000000..07399a39e92a10 --- /dev/null +++ b/tests/components/lock/conftest.py @@ -0,0 +1,141 @@ +"""Fixtures for the lock entity platform tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + LockEntity, + LockEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +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 MockLock(LockEntity): + """Mocked lock entity.""" + + def __init__( + self, + supported_features: LockEntityFeature = LockEntityFeature(0), + code_format: str | None = None, + ) -> None: + """Initialize the lock.""" + self.calls_open = MagicMock() + self.calls_lock = MagicMock() + self.calls_unlock = MagicMock() + self._attr_code_format = code_format + self._attr_supported_features = supported_features + self._attr_has_entity_name = True + self._attr_name = "test_lock" + self._attr_unique_id = "very_unique_lock_id" + super().__init__() + + def lock(self, **kwargs: Any) -> None: + """Mock lock lock calls.""" + self.calls_lock(**kwargs) + + def unlock(self, **kwargs: Any) -> None: + """Mock lock unlock calls.""" + self.calls_unlock(**kwargs) + + def open(self, **kwargs: Any) -> None: + """Mock lock open calls.""" + self.calls_open(**kwargs) + + +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 +async def code_format() -> str | None: + """Return the code format for the test lock entity.""" + return None + + +@pytest.fixture(name="supported_features") +async def lock_supported_features() -> LockEntityFeature: + """Return the supported features for the test lock entity.""" + return LockEntityFeature.OPEN + + +@pytest.fixture(name="mock_lock_entity") +async def setup_lock_platform_test_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + code_format: str | None, + supported_features: LockEntityFeature, +) -> MagicMock: + """Set up lock entity using an entity platform.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, LOCK_DOMAIN) + return True + + MockPlatform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed sensor without device class -> no name + entity = MockLock( + supported_features=supported_features, + code_format=code_format, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test lock platform via config entry.""" + async_add_entities([entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{LOCK_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + + return entity diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 24b13d48a1e560..854b89fd1d8316 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -1,11 +1,12 @@ """The tests for the lock component.""" from __future__ import annotations +import re from typing import Any -from unittest.mock import MagicMock import pytest +from homeassistant.components import lock from homeassistant.components.lock import ( ATTR_CODE, CONF_DEFAULT_CODE, @@ -18,232 +19,379 @@ STATE_LOCKING, STATE_UNLOCKED, STATE_UNLOCKING, - LockEntity, LockEntityFeature, - _async_lock, - _async_open, - _async_unlock, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.entity_registry as er -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from tests.testing_config.custom_components.test.lock import MockLock +from .conftest import MockLock +from tests.common import import_and_test_deprecated_constant_enum -class MockLockEntity(LockEntity): - """Mock lock to use in tests.""" - def __init__( - self, - code_format: str | None = None, - lock_option_default_code: str = "", - supported_features: LockEntityFeature = LockEntityFeature(0), - ) -> None: - """Initialize mock lock entity.""" - self._attr_supported_features = supported_features - self.calls_open = MagicMock() - if code_format is not None: - self._attr_code_format = code_format - self._lock_option_default_code = lock_option_default_code - - async def async_lock(self, **kwargs: Any) -> None: - """Lock the lock.""" - self._attr_is_locking = False - self._attr_is_locked = True - - async def async_unlock(self, **kwargs: Any) -> None: - """Unlock the lock.""" - self._attr_is_unlocking = False - self._attr_is_locked = False +async def help_test_async_lock_service( + hass: HomeAssistant, + entity_id: str, + service: str, + code: str | None | UndefinedType = UNDEFINED, +) -> None: + """Help to lock a test lock.""" + data: dict[str, Any] = {"entity_id": entity_id} + if code is not UNDEFINED: + data[ATTR_CODE] = code - async def async_open(self, **kwargs: Any) -> None: - """Open the door latch.""" - self.calls_open(kwargs) + await hass.services.async_call(DOMAIN, service, data, blocking=True) -async def test_lock_default(hass: HomeAssistant) -> None: +async def test_lock_default(hass: HomeAssistant, mock_lock_entity: MockLock) -> None: """Test lock entity with defaults.""" - lock = MockLockEntity() - lock.hass = hass - assert lock.code_format is None - assert lock.state is None + assert mock_lock_entity.code_format is None + assert mock_lock_entity.state is None + assert mock_lock_entity.is_jammed is None + assert mock_lock_entity.is_locked is None + assert mock_lock_entity.is_locking is None + assert mock_lock_entity.is_unlocking is None -async def test_lock_states(hass: HomeAssistant) -> None: +async def test_lock_states(hass: HomeAssistant, mock_lock_entity: MockLock) -> None: """Test lock entity states.""" - lock = MockLockEntity() - lock.hass = hass - - assert lock.state is None + assert mock_lock_entity.state is None - lock._attr_is_locking = True - assert lock.is_locking - assert lock.state == STATE_LOCKING + mock_lock_entity._attr_is_locking = True + assert mock_lock_entity.is_locking + assert mock_lock_entity.state == STATE_LOCKING - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) - assert lock.is_locked - assert lock.state == STATE_LOCKED + mock_lock_entity._attr_is_locked = True + mock_lock_entity._attr_is_locking = False + assert mock_lock_entity.is_locked + assert mock_lock_entity.state == STATE_LOCKED - lock._attr_is_unlocking = True - assert lock.is_unlocking - assert lock.state == STATE_UNLOCKING + mock_lock_entity._attr_is_unlocking = True + assert mock_lock_entity.is_unlocking + assert mock_lock_entity.state == STATE_UNLOCKING - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) - assert not lock.is_locked - assert lock.state == STATE_UNLOCKED + mock_lock_entity._attr_is_locked = False + mock_lock_entity._attr_is_unlocking = False + assert not mock_lock_entity.is_locked + assert mock_lock_entity.state == STATE_UNLOCKED - lock._attr_is_jammed = True - assert lock.is_jammed - assert lock.state == STATE_JAMMED - assert not lock.is_locked + mock_lock_entity._attr_is_jammed = True + assert mock_lock_entity.is_jammed + assert mock_lock_entity.state == STATE_JAMMED + assert not mock_lock_entity.is_locked -async def test_set_default_code_option( +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_set_mock_lock_options( hass: HomeAssistant, - enable_custom_integrations: None, + entity_registry: er.EntityRegistry, + mock_lock_entity: MockLock, ) -> 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() - - platform = getattr(hass.components, "test.lock") - platform.init(empty=True) - platform.ENTITIES["lock1"] = platform.MockLock( - name="Test", - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - unique_id="very_unique", - ) - - assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) - await hass.async_block_till_done() - - entity0: MockLock = platform.ENTITIES["lock1"] + """Test mock attributes and default code stored in the registry.""" entity_registry.async_update_entity_options( - entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "1234"} ) await hass.async_block_till_done() - assert entity0._lock_option_default_code == "1234" + assert mock_lock_entity._lock_option_default_code == "1234" + state = hass.states.get(mock_lock_entity.entity_id) + assert state is not None + assert state.attributes["code_format"] == r"^\d{4}$" + assert state.attributes["supported_features"] == LockEntityFeature.OPEN +@pytest.mark.parametrize("code_format", [r"^\d{4}$"]) async def test_default_code_option_update( hass: HomeAssistant, - enable_custom_integrations: None, + entity_registry: er.EntityRegistry, + mock_lock_entity: MockLock, ) -> 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() - platform = getattr(hass.components, "test.lock") - platform.init(empty=True) + assert mock_lock_entity._lock_option_default_code == "" - # Pre-register entities - entry = entity_registry.async_get_or_create("lock", "test", "very_unique") entity_registry.async_update_entity_options( - entry.entity_id, - "lock", - { - "default_code": "5432", - }, + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "4321"} ) - platform.ENTITIES["lock1"] = platform.MockLock( - name="Test", - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - unique_id="very_unique", - ) - - assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) await hass.async_block_till_done() - entity0: MockLock = platform.ENTITIES["lock1"] - assert entity0._lock_option_default_code == "5432" + assert mock_lock_entity._lock_option_default_code == "4321" - entity_registry.async_update_entity_options( - entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} - ) - await hass.async_block_till_done() - assert entity0._lock_option_default_code == "1234" +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_open_with_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: + """Test lock entity with open service.""" + state = hass.states.get(mock_lock_entity.entity_id) + assert state.attributes["code_format"] == r"^\d{4}$" + + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="" + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="HELLO" + ) + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="1234" + ) + assert mock_lock_entity.calls_open.call_count == 1 + mock_lock_entity.calls_open.assert_called_with(code="1234") -async def test_lock_open_with_code(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_lock_with_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: """Test lock entity with open service.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", supported_features=LockEntityFeature.OPEN + state = hass.states.get(mock_lock_entity.entity_id) + assert state.attributes["code_format"] == r"^\d{4}$" + + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="1234" ) - lock.hass = hass + mock_lock_entity.calls_unlock.assert_called_with(code="1234") + assert mock_lock_entity.calls_lock.call_count == 0 - assert lock.state_attributes == {"code_format": r"^\d{4}$"} + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="" + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="HELLO" + ) + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="1234" + ) + assert mock_lock_entity.calls_lock.call_count == 1 + mock_lock_entity.calls_lock.assert_called_with(code="1234") - with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) - with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) - with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "HELLO"})) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "1234"})) - assert lock.calls_open.call_count == 1 +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_unlock_with_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: + """Test unlock entity with open service.""" + state = hass.states.get(mock_lock_entity.entity_id) + assert state.attributes["code_format"] == r"^\d{4}$" -async def test_lock_lock_with_code(hass: HomeAssistant) -> None: - """Test lock entity with open service.""" - lock = MockLockEntity(code_format=r"^\d{4}$") - lock.hass = hass + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="1234" + ) + mock_lock_entity.calls_lock.assert_called_with(code="1234") + assert mock_lock_entity.calls_unlock.call_count == 0 - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) - assert not lock.is_locked + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="" + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="HELLO" + ) + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="1234" + ) + assert mock_lock_entity.calls_unlock.call_count == 1 + mock_lock_entity.calls_unlock.assert_called_with(code="1234") - with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) - with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) - with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "HELLO"})) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "1234"})) - assert lock.is_locked +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_with_illegal_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: + """Test lock entity with default code that does not match the code format.""" -async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: - """Test unlock entity with open service.""" - lock = MockLockEntity(code_format=r"^\d{4}$") - lock.hass = hass - - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) - assert lock.is_locked - - with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) - with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) - with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "HELLO"}) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="123456" + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="123456" ) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) - assert not lock.is_locked + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="123456" + ) + + +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(None, LockEntityFeature.OPEN)], +) +async def test_lock_with_no_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: + """Test lock entity without code.""" + await help_test_async_lock_service(hass, mock_lock_entity.entity_id, SERVICE_OPEN) + mock_lock_entity.calls_open.assert_called_with() + await help_test_async_lock_service(hass, mock_lock_entity.entity_id, SERVICE_LOCK) + mock_lock_entity.calls_lock.assert_called_with() + await help_test_async_lock_service(hass, mock_lock_entity.entity_id, SERVICE_UNLOCK) + mock_lock_entity.calls_unlock.assert_called_with() + + mock_lock_entity.calls_open.reset_mock() + mock_lock_entity.calls_lock.reset_mock() + mock_lock_entity.calls_unlock.reset_mock() + + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="" + ) + mock_lock_entity.calls_open.assert_called_with() + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="" + ) + mock_lock_entity.calls_lock.assert_called_with() + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="" + ) + mock_lock_entity.calls_unlock.assert_called_with() -async def test_lock_with_default_code(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_with_default_code( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_lock_entity: MockLock +) -> None: """Test lock entity with default code.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - lock_option_default_code="1234", + entity_registry.async_update_entity_options( + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "1234"} + ) + await hass.async_block_till_done() + + assert mock_lock_entity.state_attributes == {"code_format": r"^\d{4}$"} + assert mock_lock_entity._lock_option_default_code == "1234" + + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="1234" + ) + mock_lock_entity.calls_open.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="1234" + ) + mock_lock_entity.calls_lock.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="1234" + ) + mock_lock_entity.calls_unlock.assert_called_with(code="1234") + + mock_lock_entity.calls_open.reset_mock() + mock_lock_entity.calls_lock.reset_mock() + mock_lock_entity.calls_unlock.reset_mock() + + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="" + ) + mock_lock_entity.calls_open.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="" + ) + mock_lock_entity.calls_lock.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="" + ) + mock_lock_entity.calls_unlock.assert_called_with(code="1234") + + +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_with_illegal_default_code( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_lock_entity: MockLock +) -> None: + """Test lock entity with illegal default code.""" + entity_registry.async_update_entity_options( + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "123456"} + ) + await hass.async_block_till_done() + + assert mock_lock_entity.state_attributes == {"code_format": r"^\d{4}$"} + assert mock_lock_entity._lock_option_default_code == "" + + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK + ) + with pytest.raises( + ServiceValidationError, + match=re.escape( + rf"The code for lock.test_lock doesn't match pattern ^\d{{{4}}}$" + ), + ) as exc: + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK + ) + + assert ( + str(exc.value) + == rf"The code for lock.test_lock doesn't match pattern ^\d{{{4}}}$" ) - lock.hass = hass + assert exc.value.translation_key == "add_default_code" + + +@pytest.mark.parametrize(("enum"), list(LockEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: LockEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, lock, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" - assert lock.state_attributes == {"code_format": r"^\d{4}$"} - assert lock._lock_option_default_code == "1234" + class MockLockEntity(lock.LockEntity): + _attr_supported_features = 1 - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + entity = MockLockEntity() + assert entity.supported_features is lock.LockEntityFeature(1) + assert "MockLockEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "LockEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is lock.LockEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index eaa2a1e41922a9..671c70168d2d36 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -5,8 +5,9 @@ from datetime import datetime, timedelta from http import HTTPStatus import json -from unittest.mock import Mock, patch +from unittest.mock import Mock +from freezegun import freeze_time import pytest import voluptuous as vol @@ -493,17 +494,18 @@ def _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, + ) + ), ), ) assert await async_setup_component(hass, "logbook", {}) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire("some_event") await async_wait_recording_done(hass) @@ -565,10 +567,7 @@ def async_describe_events(hass, async_describe_event): }, ) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire( "some_automation_event", {logbook.ATTR_NAME: name, logbook.ATTR_ENTITY_ID: entity_id}, diff --git a/tests/components/lovelace/test_cast.py b/tests/components/lovelace/test_cast.py index e67ab3f841a916..4181d73c4d3618 100644 --- a/tests/components/lovelace/test_cast.py +++ b/tests/components/lovelace/test_cast.py @@ -44,7 +44,7 @@ async def mock_yaml_dashboard(hass): ) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={ "title": "YAML Title", "views": [ diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 05bc7f372b88b6..a772b37f047b90 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -141,7 +141,7 @@ async def test_lovelace_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json({"id": 7, "type": "lovelace/config"}) @@ -154,7 +154,7 @@ async def test_lovelace_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json({"id": 8, "type": "lovelace/config", "force": True}) @@ -245,7 +245,7 @@ async def test_dashboard_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json( @@ -260,7 +260,7 @@ async def test_dashboard_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json( diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index f7830f03ed6512..4a280eccfda3ed 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -38,7 +38,7 @@ async def test_yaml_resources_backwards( ) -> None: """Test defining resources in YAML ll config (legacy).""" with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"resources": RESOURCE_EXAMPLES}, ): assert await async_setup_component( diff --git a/tests/components/lovelace/test_system_health.py b/tests/components/lovelace/test_system_health.py index 7a39bc4605dd13..72e7adb3a13792 100644 --- a/tests/components/lovelace/test_system_health.py +++ b/tests/components/lovelace/test_system_health.py @@ -39,7 +39,7 @@ async def test_system_health_info_yaml(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) await hass.async_block_till_done() with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"views": [{"cards": []}]}, ): info = await get_system_health_info(hass, "lovelace") diff --git a/tests/components/luftdaten/test_sensor.py b/tests/components/luftdaten/test_sensor.py index e9e86fd9f1be01..7a2cac1721bcc4 100644 --- a/tests/components/luftdaten/test_sensor.py +++ b/tests/components/luftdaten/test_sensor.py @@ -23,11 +23,11 @@ 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 68742e5bae3aec..378db23715cbf3 100644 --- a/tests/components/lutron_caseta/test_button.py +++ b/tests/components/lutron_caseta/test_button.py @@ -8,7 +8,9 @@ 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 da26a55a4ef92e..631cb0ff1e74e3 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 ef5fc2a5228099..7fe8ed22866b71 100644 --- a/tests/components/lutron_caseta/test_cover.py +++ b/tests/components/lutron_caseta/test_cover.py @@ -7,13 +7,13 @@ 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 f9c86cc9c5856e..0147817514da9e 100644 --- a/tests/components/lutron_caseta/test_fan.py +++ b/tests/components/lutron_caseta/test_fan.py @@ -7,13 +7,13 @@ 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 6449ce048328b0..cdba9a956e54cc 100644 --- a/tests/components/lutron_caseta/test_light.py +++ b/tests/components/lutron_caseta/test_light.py @@ -8,15 +8,15 @@ 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 8390370d16d27a..c0bac43ba6f2d0 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 842aca94423ef0..c38305ec26b6c3 100644 --- a/tests/components/lutron_caseta/test_switch.py +++ b/tests/components/lutron_caseta/test_switch.py @@ -6,13 +6,13 @@ 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/matter/common.py b/tests/components/matter/common.py index a09351540543b5..d5093367db5abc 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 53477792e4303b..f591709fbdadaf 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 3c5b82ad5b898a..503fd3b9a7a5b8 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -41,11 +41,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": [], @@ -77,8 +77,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, @@ -123,8 +123,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, @@ -156,14 +156,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, @@ -316,19 +316,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, @@ -353,20 +353,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 7552fa833fb35a..45d1c18635c862 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 4b834cd90903de..1d1d450e1f097b 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.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": "869D5F986B588B29", "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, @@ -142,14 +142,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, @@ -301,19 +301,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, @@ -338,20 +338,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, @@ -414,8 +414,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 e14c922857c9cf..7ccc3eef3af21a 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 6cbd75ab09c285..dfa7794f28beaa 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 1477d78aa67eba..8a3f0fd68ddf82 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 b0eacfb621c17c..a009796f9408ee 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 00000000000000..03ff4ce7dba396 --- /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 f4d83239b6d8eb..d18b76768ca426 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 e1fc2a3658505f..a8dad202fa199e 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 15c93825307260..f564e91a1ce829 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 30763c88e5ba0f..80773915748ad5 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 a1940fc1857b4d..8220c9cf8f8764 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 93583c342920a8..c4d84bc7923663 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 d8f2580c2b03fe..f63dd43362b141 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 43ba486bc29130..8d523f5443a934 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 f29361da1281ef..3f6e83ca460bdb 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 8a1134409a9ab4..18cb68c8926c09 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 65ef0be5c8e949..eed404ff85d20b 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 a47cda28056f10..d38ac560ac51a3 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 new file mode 100644 index 00000000000000..e16f1e406ec9a9 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/switch-unit.json @@ -0,0 +1,119 @@ +{ + "node_id": 1, + "date_commissioned": "2022-11-29T21:23:48.485051", + "last_interview": "2022-11-29T21:23:48.485057", + "interview_version": 2, + "attributes": { + "0/29/0": [ + { + "0": 99999, + "1": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "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/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock SwitchUnit", + "0/40/4": 32768, + "0/40/5": "Mock SwitchUnit", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20221206", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-switch-unit", + "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, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/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/5/0": 0, + "1/5/1": 0, + "1/5/2": 0, + "1/5/3": false, + "1/5/4": 0, + "1/5/65532": 0, + "1/5/65533": 4, + "1/5/65528": [0, 1, 2, 3, 4, 6], + "1/5/65529": [0, 1, 2, 3, 4, 5, 6], + "1/5/65531": [0, 1, 2, 3, 4, 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/7/0": 0, + "1/7/16": 0, + "1/7/65532": 0, + "1/7/65533": 1, + "1/7/65528": [], + "1/7/65529": [], + "1/7/65531": [0, 16, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 9999999, + "1": 1 + } + ], + "1/29/1": [ + 3, 4, 5, 6, 7, 8, 15, 29, 30, 37, 47, 59, 64, 65, 69, 80, 257, 258, 259, + 512, 513, 514, 516, 768, 1024, 1026, 1027, 1028, 1029, 1030, 1283, 1284, + 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293, 1294, 2820, + 4294048773 + ], + "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] + }, + "available": true, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/temperature-sensor.json b/tests/components/matter/fixtures/nodes/temperature-sensor.json index c7d372ac2d74cd..0abb366f81b085 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 85ac42e5429153..a7abff41331b92 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 feb75409526f69..fc6efe2077c0f4 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 afc2a2f734f6f4..9c58869e988c28 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 8d3335bbd6c456..fe970b6ed6beda 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 44347dbd964541..92a1d820d2e391 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 a33e0f24c3f33a..144348b5c76887 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 8ed309f61df468..35e6673114e406 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_api.py b/tests/components/matter/test_api.py index 041920f653f806..24dac910d33ba2 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -32,7 +32,7 @@ async def test_commission( msg = await ws_client.receive_json() assert msg["success"] - matter_client.commission_with_code.assert_called_once_with("12345678") + matter_client.commission_with_code.assert_called_once_with("12345678", True) matter_client.commission_with_code.reset_mock() matter_client.commission_with_code.side_effect = InvalidCommand( @@ -40,17 +40,13 @@ async def test_commission( ) await ws_client.send_json( - { - ID: 2, - TYPE: "matter/commission", - "code": "12345678", - } + {ID: 2, TYPE: "matter/commission", "code": "12345678", "network_only": False} ) msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "9" - matter_client.commission_with_code.assert_called_once_with("12345678") + matter_client.commission_with_code.assert_called_once_with("12345678", False) # This tests needs to be adjusted to remove lingering tasks @@ -74,7 +70,7 @@ async def test_commission_on_network( msg = await ws_client.receive_json() assert msg["success"] - matter_client.commission_on_network.assert_called_once_with(1234) + matter_client.commission_on_network.assert_called_once_with(1234, None) matter_client.commission_on_network.reset_mock() matter_client.commission_on_network.side_effect = NodeCommissionFailed( @@ -82,17 +78,13 @@ async def test_commission_on_network( ) await ws_client.send_json( - { - ID: 2, - TYPE: "matter/commission_on_network", - "pin": 1234, - } + {ID: 2, TYPE: "matter/commission_on_network", "pin": 1234, "ip_addr": "1.2.3.4"} ) msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "1" - matter_client.commission_on_network.assert_called_once_with(1234) + matter_client.commission_on_network.assert_called_once_with(1234, "1.2.3.4") # This tests needs to be adjusted to remove lingering tasks diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 4dbb3b27b9c6ef..e231012f90db53 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_climate.py b/tests/components/matter/test_climate.py index ec8453b5c560a1..81d210ed579967 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -6,18 +6,7 @@ from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest -from homeassistant.components.climate import ( - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVACAction, - HVACMode, -) -from homeassistant.components.climate.const import ( - HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, - HVAC_MODE_OFF, -) +from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.core import HomeAssistant from .common import ( @@ -51,7 +40,7 @@ async def test_thermostat( # test set temperature when target temp is None assert state.attributes["temperature"] is None - assert state.state == HVAC_MODE_COOL + assert state.state == HVACMode.COOL with pytest.raises( ValueError, match="Current target_temperature should not be None" ): @@ -85,7 +74,7 @@ async def test_thermostat( ): state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_HEAT_COOL + assert state.state == HVACMode.HEAT_COOL await hass.services.async_call( "climate", "set_temperature", @@ -119,19 +108,19 @@ async def test_thermostat( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_OFF + assert state.state == HVACMode.OFF set_node_attribute(thermostat, 1, 513, 28, 7) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_FAN_ONLY + assert state.state == HVACMode.FAN_ONLY set_node_attribute(thermostat, 1, 513, 28, 8) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_DRY + assert state.state == HVACMode.DRY # test running state update from device set_node_attribute(thermostat, 1, 513, 41, 1) @@ -188,7 +177,7 @@ async def test_thermostat( state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_HEAT + assert state.state == HVACMode.HEAT # change occupied heating setpoint to 20 set_node_attribute(thermostat, 1, 513, 18, 2000) @@ -225,7 +214,7 @@ async def test_thermostat( state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_COOL + assert state.state == HVACMode.COOL # change occupied cooling setpoint to 18 set_node_attribute(thermostat, 1, 513, 17, 1800) @@ -273,7 +262,7 @@ async def test_thermostat( state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_HEAT_COOL + assert state.state == HVACMode.HEAT_COOL # change occupied cooling setpoint to 18 set_node_attribute(thermostat, 1, 513, 17, 2500) @@ -340,7 +329,7 @@ async def test_thermostat( "set_hvac_mode", { "entity_id": "climate.longan_link_hvac", - "hvac_mode": HVAC_MODE_HEAT, + "hvac_mode": HVACMode.HEAT, }, blocking=True, ) diff --git a/tests/components/matter/test_diagnostics.py b/tests/components/matter/test_diagnostics.py index 303e9879c56327..c14eb93f24cf13 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_door_lock.py b/tests/components/matter/test_door_lock.py index a9753824edcf87..51d48cddba7313 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -14,6 +14,8 @@ ) from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.entity_registry as er from .common import set_node_attribute, trigger_subscription_callback @@ -101,6 +103,7 @@ async def test_lock_requires_pin( hass: HomeAssistant, matter_client: MagicMock, door_lock: MatterNode, + entity_registry: er.EntityRegistry, ) -> None: """Test door lock with PINCode.""" @@ -111,7 +114,7 @@ async def test_lock_requires_pin( # set door state to unlocked set_node_attribute(door_lock, 1, 257, 0, 2) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): # Lock door using invalid code format await trigger_subscription_callback(hass, matter_client) await hass.services.async_call( @@ -137,6 +140,26 @@ async def test_lock_requires_pin( timed_request_timeout_ms=1000, ) + # Lock door using default code + default_code = "7654321" + entity_registry.async_update_entity_options( + "lock.mock_door_lock", "lock", {"default_code": default_code} + ) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + "lock", + "lock", + {"entity_id": "lock.mock_door_lock"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.LockDoor(default_code.encode()), + timed_request_timeout_ms=1000, + ) + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index f7399d6aaf1c46..61988a371226a7 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) @@ -60,16 +60,13 @@ async def test_get_node_from_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 bbe77b76af58ca..2286249bd5d87e 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 2650f2b1a6ff36..5b343b8c4e54a1 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 @@ 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/matter/test_switch.py b/tests/components/matter/test_switch.py index 6fbe5d58f289ef..ac03d731ee1b2c 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -14,22 +14,30 @@ ) -@pytest.fixture(name="switch_node") -async def switch_node_fixture( +@pytest.fixture(name="powerplug_node") +async def powerplug_node_fixture( hass: HomeAssistant, matter_client: MagicMock ) -> MatterNode: - """Fixture for a switch node.""" + """Fixture for a Powerplug node.""" return await setup_integration_with_node_fixture( hass, "on-off-plugin-unit", matter_client ) +@pytest.fixture(name="switch_unit") +async def switch_unit_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Switch Unit node.""" + return await setup_integration_with_node_fixture(hass, "switch-unit", matter_client) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_turn_on( hass: HomeAssistant, matter_client: MagicMock, - switch_node: MatterNode, + powerplug_node: MatterNode, ) -> None: """Test turning on a switch.""" state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") @@ -47,12 +55,12 @@ async def test_turn_on( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( - node_id=switch_node.node_id, + node_id=powerplug_node.node_id, endpoint_id=1, command=clusters.OnOff.Commands.On(), ) - set_node_attribute(switch_node, 1, 6, 0, True) + set_node_attribute(powerplug_node, 1, 6, 0, True) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") @@ -65,7 +73,7 @@ async def test_turn_on( async def test_turn_off( hass: HomeAssistant, matter_client: MagicMock, - switch_node: MatterNode, + powerplug_node: MatterNode, ) -> None: """Test turning off a switch.""" state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") @@ -83,7 +91,24 @@ async def test_turn_off( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( - node_id=switch_node.node_id, + node_id=powerplug_node.node_id, endpoint_id=1, command=clusters.OnOff.Commands.Off(), ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_switch_unit( + hass: HomeAssistant, + matter_client: MagicMock, + switch_unit: MatterNode, +) -> None: + """Test if a switch entity is discovered from any (non-light) OnOf cluster device.""" + # A switch entity should be discovered as fallback for ANY Matter device (endpoint) + # that has the OnOff cluster and does not fall into an explicit discovery schema + # by another platform (e.g. light, lock etc.). + state = hass.states.get("switch.mock_switchunit") + assert state + assert state.state == "off" + assert state.attributes["friendly_name"] == "Mock SwitchUnit" diff --git a/tests/components/maxcube/test_maxcube_binary_sensor.py b/tests/components/maxcube/test_maxcube_binary_sensor.py index 65991f91b7b667..0c73c548211481 100644 --- a/tests/components/maxcube/test_maxcube_binary_sensor.py +++ b/tests/components/maxcube/test_maxcube_binary_sensor.py @@ -23,10 +23,12 @@ 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 3682c98e947ed6..3f2b325330e0bd 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -50,6 +50,7 @@ ATTR_TEMPERATURE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.util import utcnow @@ -60,9 +61,10 @@ 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 +98,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" @@ -368,7 +371,7 @@ async def test_thermostat_set_invalid_preset( hass: HomeAssistant, cube: MaxCube, thermostat: MaxThermostat ) -> None: """Set hvac mode to heat.""" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py index c60f67031cfd50..d32ad90d87c479 100644 --- a/tests/components/media_extractor/test_init.py +++ b/tests/components/media_extractor/test_init.py @@ -1,4 +1,6 @@ """The tests for Media Extractor integration.""" +import os +import os.path from typing import Any from unittest.mock import patch @@ -209,3 +211,60 @@ async def test_query_error( await hass.async_block_till_done() assert len(calls) == 0 + + +async def test_cookiefile_detection( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], + snapshot: SnapshotAssertion, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test cookie file detection.""" + + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + cookies_dir = os.path.join(hass.config.config_dir, "media_extractor") + cookies_file = os.path.join(cookies_dir, "cookies.txt") + + if not os.path.exists(cookies_dir): + os.makedirs(cookies_dir) + + f = open(cookies_file, "w+", encoding="utf-8") + f.write( + """# Netscape HTTP Cookie File + + .youtube.com TRUE / TRUE 1701708706 GPS 1 + """ + ) + f.close() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_PLAYLIST, + }, + ) + await hass.async_block_till_done() + + assert "Media extractor loaded cookies file" in caplog.text + + os.remove(cookies_file) + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_PLAYLIST, + }, + ) + await hass.async_block_till_done() + + assert "Media extractor didn't find cookies file" in caplog.text diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index cf71b52c046cf4..f3b70187f337df 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -10,9 +10,10 @@ STATE_PLAYING, STATE_STANDBY, ) +from homeassistant.core import HomeAssistant -class ExtendedMediaPlayer(mp.MediaPlayerEntity): +class SimpleMediaPlayer(mp.MediaPlayerEntity): """Media player test class.""" def __init__(self, hass): @@ -47,16 +48,6 @@ def set_volume_level(self, volume): """Set volume level, range 0..1.""" self._volume = volume - def volume_up(self): - """Turn volume up for media player.""" - if self.volume_level < 1: - self.set_volume_level(min(1, self.volume_level + 0.1)) - - def volume_down(self): - """Turn volume down for media player.""" - if self.volume_level > 0: - self.set_volume_level(max(0, self.volume_level - 0.1)) - def media_play(self): """Play the media player.""" self._state = STATE_PLAYING @@ -65,13 +56,6 @@ def media_pause(self): """Plause the media player.""" self._state = STATE_PAUSED - def media_play_pause(self): - """Play or pause the media player.""" - if self._state == STATE_PLAYING: - self._state = STATE_PAUSED - else: - self._state = STATE_PLAYING - def turn_on(self): """Turn on state.""" self._state = STATE_ON @@ -84,68 +68,45 @@ def standby(self): """Put device in standby.""" self._state = STATE_STANDBY - def toggle(self): - """Toggle the power on the media player.""" - if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: - self._state = STATE_ON - else: - self._state = STATE_OFF - -class SimpleMediaPlayer(mp.MediaPlayerEntity): +class ExtendedMediaPlayer(SimpleMediaPlayer): """Media player test class.""" - def __init__(self, hass): - """Initialize the test media player.""" - self.hass = hass - self._volume = 0 - self._state = STATE_OFF + def volume_up(self): + """Turn volume up for media player.""" + if self.volume_level < 1: + self.set_volume_level(min(1, self.volume_level + 0.1)) - @property - def state(self): - """State of the player.""" - return self._state + def volume_down(self): + """Turn volume down for media player.""" + if self.volume_level > 0: + self.set_volume_level(max(0, self.volume_level - 0.1)) - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume + def media_play_pause(self): + """Play or pause the media player.""" + if self._state == STATE_PLAYING: + self._state = STATE_PAUSED + else: + self._state = STATE_PLAYING - @property - def supported_features(self): - """Flag media player features that are supported.""" - return ( - mp.const.MediaPlayerEntityFeature.VOLUME_SET - | mp.const.MediaPlayerEntityFeature.VOLUME_STEP - | mp.const.MediaPlayerEntityFeature.PLAY - | mp.const.MediaPlayerEntityFeature.PAUSE - | mp.const.MediaPlayerEntityFeature.TURN_OFF - | mp.const.MediaPlayerEntityFeature.TURN_ON - ) + def toggle(self): + """Toggle the power on the media player.""" + if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: + self._state = STATE_ON + else: + self._state = STATE_OFF - def set_volume_level(self, volume): - """Set volume level, range 0..1.""" - self._volume = volume - def media_play(self): - """Play the media player.""" - self._state = STATE_PLAYING +class AttrMediaPlayer(SimpleMediaPlayer): + """Media player setting properties via _attr_*.""" - def media_pause(self): - """Plause the media player.""" - self._state = STATE_PAUSED + _attr_volume_step = 0.2 - def turn_on(self): - """Turn on state.""" - self._state = STATE_ON - def turn_off(self): - """Turn off state.""" - self._state = STATE_OFF +class DescrMediaPlayer(SimpleMediaPlayer): + """Media player setting properties via entity description.""" - def standby(self): - """Put device in standby.""" - self._state = STATE_STANDBY + entity_description = mp.MediaPlayerEntityDescription(key="test", volume_step=0.3) @pytest.fixture(params=[ExtendedMediaPlayer, SimpleMediaPlayer]) @@ -154,22 +115,46 @@ def player(hass, request): return request.param(hass) -async def test_volume_up(player) -> None: +@pytest.mark.parametrize( + ("player_class", "volume_step"), + [ + (ExtendedMediaPlayer, 0.1), + (SimpleMediaPlayer, 0.1), + (AttrMediaPlayer, 0.2), + (DescrMediaPlayer, 0.3), + ], +) +async def test_volume_up( + hass: HomeAssistant, player_class: type[mp.MediaPlayerEntity], volume_step: float +) -> None: """Test the volume_up and set volume methods.""" + player = player_class(hass) assert player.volume_level == 0 await player.async_set_volume_level(0.5) assert player.volume_level == 0.5 await player.async_volume_up() - assert player.volume_level == 0.6 + assert player.volume_level == 0.5 + volume_step -async def test_volume_down(player) -> None: +@pytest.mark.parametrize( + ("player_class", "volume_step"), + [ + (ExtendedMediaPlayer, 0.1), + (SimpleMediaPlayer, 0.1), + (AttrMediaPlayer, 0.2), + (DescrMediaPlayer, 0.3), + ], +) +async def test_volume_down( + hass: HomeAssistant, player_class: type[mp.MediaPlayerEntity], volume_step: float +) -> None: """Test the volume_down and set volume methods.""" + player = player_class(hass) assert player.volume_level == 0 await player.async_set_volume_level(0.5) assert player.volume_level == 0.5 await player.async_volume_down() - assert player.volume_level == 0.4 + assert player.volume_level == 0.5 - volume_step async def test_media_play_pause(player) -> None: diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index b7bf35ab2f8b1a..b4228d1ee69778 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -10,6 +10,7 @@ BrowseMedia, MediaClass, MediaPlayerEnqueue, + MediaPlayerEntity, MediaPlayerEntityFeature, ) from homeassistant.components.websocket_api.const import TYPE_RESULT @@ -159,9 +160,6 @@ async def test_media_browse( client = await hass_ws_client(hass) with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.BROWSE_MEDIA, - ), patch( "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", return_value=BrowseMedia( media_class=MediaClass.DIRECTORY, @@ -176,7 +174,7 @@ async def test_media_browse( { "id": 5, "type": "media_player/browse_media", - "entity_id": "media_player.bedroom", + "entity_id": "media_player.browse", "media_content_type": "album", "media_content_id": "abcd", } @@ -202,9 +200,6 @@ async def test_media_browse( assert mock_browse_media.mock_calls[0][1] == ("album", "abcd") with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.BROWSE_MEDIA, - ), patch( "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", return_value={"bla": "yo"}, ): @@ -212,7 +207,7 @@ async def test_media_browse( { "id": 6, "type": "media_player/browse_media", - "entity_id": "media_player.bedroom", + "entity_id": "media_player.browse", } ) @@ -231,19 +226,14 @@ async def test_group_members_available_when_off(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - # Fake group support for DemoYoutubePlayer - with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.TURN_OFF, - ): - await hass.services.async_call( - "media_player", - "turn_off", - {ATTR_ENTITY_ID: "media_player.bedroom"}, - blocking=True, - ) + await hass.services.async_call( + "media_player", + "turn_off", + {ATTR_ENTITY_ID: "media_player.group"}, + blocking=True, + ) - state = hass.states.get("media_player.bedroom") + state = hass.states.get("media_player.group") assert state.state == STATE_OFF assert "group_members" in state.attributes @@ -339,3 +329,23 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockMediaPlayerEntity(MediaPlayerEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockMediaPlayerEntity() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "MockMediaPlayerEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "MediaPlayerEntityFeature.PAUSE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/media_player/test_significant_change.py b/tests/components/media_player/test_significant_change.py new file mode 100644 index 00000000000000..233f133c342def --- /dev/null +++ b/tests/components/media_player/test_significant_change.py @@ -0,0 +1,130 @@ +"""Test the Media Player significant change platform.""" +import pytest + +from homeassistant.components.media_player import ( + ATTR_APP_ID, + ATTR_APP_NAME, + ATTR_ENTITY_PICTURE_LOCAL, + ATTR_GROUP_MEMBERS, + ATTR_INPUT_SOURCE, + ATTR_MEDIA_ALBUM_ARTIST, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CHANNEL, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_EPISODE, + ATTR_MEDIA_PLAYLIST, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEASON, + ATTR_MEDIA_SERIES_TITLE, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_TRACK, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, +) +from homeassistant.components.media_player.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Media Player significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_APP_ID: "old_value"}, {ATTR_APP_ID: "old_value"}, False), + ({ATTR_APP_ID: "old_value"}, {ATTR_APP_ID: "new_value"}, True), + ({ATTR_APP_NAME: "old_value"}, {ATTR_APP_NAME: "new_value"}, True), + ( + {ATTR_ENTITY_PICTURE_LOCAL: "old_value"}, + {ATTR_ENTITY_PICTURE_LOCAL: "new_value"}, + True, + ), + ( + {ATTR_GROUP_MEMBERS: ["old1", "old2"]}, + {ATTR_GROUP_MEMBERS: ["old1", "new"]}, + False, + ), + ({ATTR_INPUT_SOURCE: "old_value"}, {ATTR_INPUT_SOURCE: "new_value"}, True), + ( + {ATTR_MEDIA_ALBUM_ARTIST: "old_value"}, + {ATTR_MEDIA_ALBUM_ARTIST: "new_value"}, + True, + ), + ( + {ATTR_MEDIA_ALBUM_NAME: "old_value"}, + {ATTR_MEDIA_ALBUM_NAME: "new_value"}, + True, + ), + ({ATTR_MEDIA_ARTIST: "old_value"}, {ATTR_MEDIA_ARTIST: "new_value"}, True), + ({ATTR_MEDIA_CHANNEL: "old_value"}, {ATTR_MEDIA_CHANNEL: "new_value"}, True), + ( + {ATTR_MEDIA_CONTENT_ID: "old_value"}, + {ATTR_MEDIA_CONTENT_ID: "new_value"}, + True, + ), + ( + {ATTR_MEDIA_CONTENT_TYPE: "old_value"}, + {ATTR_MEDIA_CONTENT_TYPE: "new_value"}, + True, + ), + ({ATTR_MEDIA_DURATION: "old_value"}, {ATTR_MEDIA_DURATION: "new_value"}, True), + ({ATTR_MEDIA_EPISODE: "old_value"}, {ATTR_MEDIA_EPISODE: "new_value"}, True), + ({ATTR_MEDIA_PLAYLIST: "old_value"}, {ATTR_MEDIA_PLAYLIST: "new_value"}, True), + ({ATTR_MEDIA_REPEAT: "old_value"}, {ATTR_MEDIA_REPEAT: "new_value"}, True), + ({ATTR_MEDIA_SEASON: "old_value"}, {ATTR_MEDIA_SEASON: "new_value"}, True), + ( + {ATTR_MEDIA_SERIES_TITLE: "old_value"}, + {ATTR_MEDIA_SERIES_TITLE: "new_value"}, + True, + ), + ({ATTR_MEDIA_SHUFFLE: "old_value"}, {ATTR_MEDIA_SHUFFLE: "new_value"}, True), + ({ATTR_MEDIA_TITLE: "old_value"}, {ATTR_MEDIA_TITLE: "new_value"}, True), + ({ATTR_MEDIA_TRACK: "old_value"}, {ATTR_MEDIA_TRACK: "new_value"}, True), + ( + {ATTR_MEDIA_VOLUME_MUTED: "old_value"}, + {ATTR_MEDIA_VOLUME_MUTED: "new_value"}, + True, + ), + ({ATTR_SOUND_MODE: "old_value"}, {ATTR_SOUND_MODE: "new_value"}, True), + # multiple attributes + ( + {ATTR_SOUND_MODE: "old_value", ATTR_MEDIA_VOLUME_MUTED: "old_value"}, + {ATTR_SOUND_MODE: "new_value", ATTR_MEDIA_VOLUME_MUTED: "old_value"}, + True, + ), + # float attributes + ({ATTR_MEDIA_VOLUME_LEVEL: 0.1}, {ATTR_MEDIA_VOLUME_LEVEL: 0.2}, True), + ({ATTR_MEDIA_VOLUME_LEVEL: 0.1}, {ATTR_MEDIA_VOLUME_LEVEL: 0.19}, False), + ({ATTR_MEDIA_VOLUME_LEVEL: "invalid"}, {ATTR_MEDIA_VOLUME_LEVEL: 1}, True), + ({ATTR_MEDIA_VOLUME_LEVEL: 1}, {ATTR_MEDIA_VOLUME_LEVEL: "invalid"}, False), + # insignificant attributes + ({ATTR_MEDIA_POSITION: "old_value"}, {ATTR_MEDIA_POSITION: "new_value"}, False), + ( + {ATTR_MEDIA_POSITION_UPDATED_AT: "old_value"}, + {ATTR_MEDIA_POSITION_UPDATED_AT: "new_value"}, + False, + ), + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Media Player significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index 8f877eb1ecaef7..f3d49f3c0bcdee 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -9,7 +9,9 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.melcloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -287,3 +289,158 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) entry = entries[0] assert entry.data["username"] == "test-email@test-domain.com" assert entry.data["token"] == "test-token" + + +async def test_token_reauthentication( + hass: HomeAssistant, + mock_login, + mock_get_devices, +) -> None: + """Re-configuration with existing username should refresh token, if made invalid.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (asyncio.TimeoutError(), "cannot_connect"), + (AttributeError(name="get"), "invalid_auth"), + ], +) +async def test_form_errors_reauthentication( + hass: HomeAssistant, mock_login, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = error + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == reason + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (HTTPStatus.UNAUTHORIZED, "invalid_auth"), + (HTTPStatus.FORBIDDEN, "invalid_auth"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), + ], +) +async def test_client_errors_reauthentication( + hass: HomeAssistant, mock_login, mock_request_info, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = ClientResponseError(mock_request_info(), (), status=error) + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["errors"]["base"] == reason + assert result["type"] == FlowResultType.FORM + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/met/test_init.py b/tests/components/met/test_init.py index 652763947dfe9b..0e4e46b09da67e 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 5a28b8eceb04b7..432c288383a7f2 100644 --- a/tests/components/met/test_weather.py +++ b/tests/components/met/test_weather.py @@ -6,21 +6,23 @@ 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 81d7a52aa06ab6..90f36d09d25ba8 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 a3ca1fd55f79c5..e5c2c66b626fa0 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.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 38df9f04ab2db9..108a9330403ffc 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 a9e286907d5035..10ed0a83f0ce2f 100644 --- a/tests/components/metoffice/test_init.py +++ b/tests/components/metoffice/test_init.py @@ -89,6 +89,7 @@ ) 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 8930d318ec78c1..19c27873d5e536 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -13,7 +13,8 @@ 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", diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index 158f86fe452d8e..8e3d5eda19d8b1 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -175,7 +175,12 @@ async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: wireless_data: list[dict[str, Any]] = kwargs.get("wireless_data", WIRELESS_DATA) wifiwave2_data: list[dict[str, Any]] = kwargs.get("wifiwave2_data", WIFIWAVE2_DATA) - def mock_command(self, cmd: str, params: dict[str, Any] | None = None) -> Any: + def mock_command( + self, + cmd: str, + params: dict[str, Any] | None = None, + suppress_errors: bool = False, + ) -> Any: if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: return support_wireless if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIFIWAVE2]: diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 84fcfabffeeeb6..bf1dc3abedfdfe 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -52,7 +52,9 @@ def mock_device_registry_devices(hass: HomeAssistant) -> None: ) -def mock_command(self, cmd: str, params: dict[str, Any] | None = None) -> Any: +def mock_command( + self, cmd: str, params: dict[str, Any] | None = None, suppress_errors: bool = False +) -> Any: """Mock the Mikrotik command method.""" if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: return True @@ -208,29 +210,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 694e9537a8ca9d..15175dedada9ab 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 8d8eac5c7009af..cd07f7060f6830 100644 --- a/tests/components/min_max/test_init.py +++ b/tests/components/min_max/test_init.py @@ -11,6 +11,7 @@ @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 a742260daff22c..acd42f9355e659 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/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr index ef03e36343b930..2a62fea7f357dc 100644 --- a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-status_response1] +# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -13,7 +13,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor[java_mock_config_entry-JavaServer-status_response0] +# name: test_binary_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -27,7 +27,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1] +# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -41,7 +41,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-status_response0] +# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr index fed0ae93c6669b..b0f77f27b80b0c 100644 --- a/tests/components/minecraft_server/snapshots/test_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -13,7 +13,7 @@ 'state': '5', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -27,7 +27,7 @@ 'state': '3', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -41,7 +41,7 @@ 'state': '10', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -54,7 +54,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -67,7 +67,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -80,7 +80,7 @@ 'state': '123', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Map name', @@ -93,7 +93,7 @@ 'state': 'Dummy Map Name', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Game mode', @@ -106,7 +106,7 @@ 'state': 'Dummy Game Mode', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Edition', @@ -119,7 +119,7 @@ 'state': 'MCPE', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0] +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -133,7 +133,7 @@ 'state': '5', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -152,7 +152,7 @@ 'state': '3', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -166,7 +166,7 @@ 'state': '10', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -179,7 +179,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -192,7 +192,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -205,7 +205,7 @@ 'state': '123', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -219,7 +219,7 @@ 'state': '5', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -233,7 +233,7 @@ 'state': '3', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -247,7 +247,7 @@ 'state': '10', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -260,7 +260,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -273,7 +273,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -286,7 +286,7 @@ 'state': '123', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Map name', @@ -299,7 +299,7 @@ 'state': 'Dummy Map Name', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Game mode', @@ -312,7 +312,7 @@ 'state': 'Dummy Game Mode', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Edition', @@ -325,7 +325,7 @@ 'state': 'MCPE', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0] +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -339,7 +339,7 @@ 'state': '5', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -358,7 +358,7 @@ 'state': '3', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -372,7 +372,7 @@ 'state': '10', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -385,7 +385,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -398,7 +398,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 9fae35b113df89..4db564bc143dc3 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -22,16 +22,27 @@ @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, snapshot: SnapshotAssertion, @@ -41,7 +52,7 @@ async def test_binary_sensor( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -53,16 +64,27 @@ async def test_binary_sensor( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor_update( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, snapshot: SnapshotAssertion, @@ -73,7 +95,7 @@ async def test_binary_sensor_update( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -88,16 +110,27 @@ async def test_binary_sensor_update( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor_update_failure( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, freezer: FrozenDateTimeFactory, @@ -107,7 +140,7 @@ async def test_binary_sensor_update_failure( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 785905492c1088..2a0208f2251099 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -41,7 +41,7 @@ async def test_address_validation_failure(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( @@ -58,7 +58,7 @@ async def test_java_connection_failure(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -95,7 +95,7 @@ async def test_java_connection(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -138,7 +138,7 @@ async def test_recovery(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/minecraft_server/test_diagnostics.py b/tests/components/minecraft_server/test_diagnostics.py index 6979325fa0c8d3..80b5c91c1fb4a1 100644 --- a/tests/components/minecraft_server/test_diagnostics.py +++ b/tests/components/minecraft_server/test_diagnostics.py @@ -42,9 +42,14 @@ async def test_config_entry_diagnostics( mock_config_entry = request.getfixturevalue(mock_config_entry) mock_config_entry.add_to_hass(hass) + if server.__name__ == "JavaServer": + lookup_function_name = "async_lookup" + else: + lookup_function_name = "lookup" + # Setup mock entry. with patch( - f"mcstatus.server.{server.__name__}.lookup", + f"mcstatus.server.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"mcstatus.server.{server.__name__}.async_status", diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 09e411f0b62e30..5b0d9509d692df 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -122,7 +122,7 @@ async def test_setup_and_unload_entry( java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -138,14 +138,14 @@ async def test_setup_and_unload_entry( assert java_mock_config_entry.state == ConfigEntryState.NOT_LOADED -async def test_setup_entry_failure( +async def test_setup_entry_lookup_failure( hass: HomeAssistant, java_mock_config_entry: MockConfigEntry ) -> None: - """Test failed entry setup.""" + """Test lookup failure in entry setup.""" java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): assert not await hass.config_entries.async_setup( @@ -156,6 +156,24 @@ async def test_setup_entry_failure( assert java_mock_config_entry.state == ConfigEntryState.SETUP_ERROR +async def test_setup_entry_init_failure( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test init failure in entry setup.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_initialize", + side_effect=None, + ): + assert not await hass.config_entries.async_setup( + java_mock_config_entry.entry_id + ) + + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.SETUP_RETRY + + async def test_setup_entry_not_ready( hass: HomeAssistant, java_mock_config_entry: MockConfigEntry ) -> None: @@ -163,7 +181,7 @@ async def test_setup_entry_not_ready( java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -178,7 +196,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) @@ -193,7 +214,7 @@ async def test_entry_migration( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=[ ValueError, # async_migrate_entry JavaServer(host=TEST_HOST, port=TEST_PORT), # async_migrate_entry @@ -218,12 +239,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 ( @@ -257,7 +276,7 @@ async def test_entry_migration_host_only( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -292,7 +311,7 @@ async def test_entry_migration_v3_failure( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=[ ValueError, # async_migrate_entry ValueError, # async_migrate_entry diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index 006c735e034ef7..7d599669d71e27 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -55,17 +55,25 @@ @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -75,6 +83,7 @@ async def test_sensor( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -85,7 +94,7 @@ async def test_sensor( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -98,17 +107,25 @@ async def test_sensor( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, ), @@ -118,6 +135,7 @@ async def test_sensor_disabled_by_default( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -127,7 +145,7 @@ async def test_sensor_disabled_by_default( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -141,17 +159,25 @@ async def test_sensor_disabled_by_default( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -161,6 +187,7 @@ async def test_sensor_update( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -172,7 +199,7 @@ async def test_sensor_update( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -189,17 +216,25 @@ async def test_sensor_update( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -209,6 +244,7 @@ async def test_sensor_update_failure( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -219,7 +255,7 @@ async def test_sensor_update_failure( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", diff --git a/tests/components/mobile_app/test_binary_sensor.py b/tests/components/mobile_app/test_binary_sensor.py index b8a6cbb6db6577..fe3510865fc754 100644 --- a/tests/components/mobile_app/test_binary_sensor.py +++ b/tests/components/mobile_app/test_binary_sensor.py @@ -9,7 +9,10 @@ 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 8b034fb4ba9cb0..d504703c222339 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -1,16 +1,34 @@ """Tests for the mobile app integration.""" -from homeassistant.components.mobile_app.const import DATA_DELETED_IDS, DOMAIN +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.mobile_app.const import ( + ATTR_DEVICE_NAME, + CONF_CLOUDHOOK_URL, + CONF_USER_ID, + DATA_DELETED_IDS, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import CALL_SERVICE +from .const import CALL_SERVICE, REGISTER_CLEARTEXT -from tests.common import async_mock_service +from tests.common import ( + MockConfigEntry, + MockUser, + async_mock_cloud_connection_status, + async_mock_service, +) -async def test_unload_unloads( - hass: HomeAssistant, create_registrations, webhook_client -) -> None: +@pytest.mark.usefixtures("create_registrations") +async def test_unload_unloads(hass: HomeAssistant, webhook_client) -> None: """Test we clean up when we unload.""" # Second config entry is the one without encryption config_entry = hass.config_entries.async_entries("mobile_app")[1] @@ -28,14 +46,111 @@ async def test_unload_unloads( assert len(calls) == 1 -async def test_remove_entry(hass: HomeAssistant, create_registrations) -> None: +@pytest.mark.usefixtures("create_registrations") +async def test_remove_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> 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 + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == 0 + + +async def _test_create_cloud_hook( + hass: HomeAssistant, + hass_admin_user: MockUser, + additional_config: dict[str, Any], + async_active_subscription_return_value: bool, + additional_steps: Callable[[ConfigEntry, Mock, str], Awaitable[None]], +) -> None: + config_entry = MockConfigEntry( + data={ + **REGISTER_CLEARTEXT, + CONF_WEBHOOK_ID: "test-webhook-id", + ATTR_DEVICE_NAME: "Test", + ATTR_DEVICE_ID: "Test", + CONF_USER_ID: hass_admin_user.id, + **additional_config, + }, + domain=DOMAIN, + title="Test", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.cloud.async_active_subscription", + return_value=async_active_subscription_return_value, + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", autospec=True + ) as mock_create_cloudhook: + cloud_hook = "https://hook-url" + mock_create_cloudhook.return_value = cloud_hook + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + await additional_steps(config_entry, mock_create_cloudhook, cloud_hook) + + +async def test_create_cloud_hook_on_setup( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test creating a cloud hook during setup.""" + + async def additional_steps( + config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str + ) -> None: + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook + mock_create_cloudhook.assert_called_once_with( + hass, config_entry.data[CONF_WEBHOOK_ID] + ) + + await _test_create_cloud_hook(hass, hass_admin_user, {}, True, additional_steps) + + +async def test_create_cloud_hook_aleady_exists( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test creating a cloud hook is not called, when a cloud hook already exists.""" + cloud_hook = "https://hook-url-already-exists" + + async def additional_steps( + config_entry: ConfigEntry, mock_create_cloudhook: Mock, _: str + ) -> None: + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook + mock_create_cloudhook.assert_not_called() + + await _test_create_cloud_hook( + hass, hass_admin_user, {CONF_CLOUDHOOK_URL: cloud_hook}, True, additional_steps + ) + + +async def test_create_cloud_hook_after_connection( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test creating a cloud hook when connected to the cloud.""" + + async def additional_steps( + config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str + ) -> None: + assert CONF_CLOUDHOOK_URL not in config_entry.data + mock_create_cloudhook.assert_not_called() + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook + mock_create_cloudhook.assert_called_once_with( + hass, config_entry.data[CONF_WEBHOOK_ID] + ) - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 0 + await _test_create_cloud_hook(hass, hass_admin_user, {}, False, additional_steps) diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index 8c8bf45fde272d..f7c4a5690db856 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -25,6 +25,8 @@ ) 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 9f6aec404e2b37..6fe272fbc405b6 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 2069aa23b8fc0f..e47a6165b30de1 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -1,5 +1,4 @@ """Thetests for the Modbus sensor component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN @@ -10,7 +9,6 @@ CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT, MODBUS_DOMAIN, @@ -26,13 +24,12 @@ STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") SLAVE_UNIQUE_ID = "ground_floor_sensor" @@ -57,7 +54,6 @@ CONF_SLAVE: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -69,7 +65,6 @@ CONF_DEVICE_ADDRESS: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -196,44 +191,6 @@ async def test_all_binary_sensor(hass: HomeAssistant, expected, mock_do_cycle) - assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_BINARY_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [False * 16], - True, - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_binary_sensor( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ @@ -445,11 +402,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_climate.py b/tests/components/modbus/test_climate.py index f2de0177c74f03..325b68869e0771 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -1,17 +1,36 @@ """The tests for the Modbus climate component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, HVACMode, ) from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -22,7 +41,6 @@ CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, CONF_HVAC_ONOFF_REGISTER, - CONF_LAZY_ERROR, CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, @@ -40,7 +58,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") @@ -77,7 +95,6 @@ CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, CONF_DATA_TYPE: DataType.INT32, - CONF_LAZY_ERROR: 10, } ], }, @@ -186,7 +203,7 @@ async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: ], ) async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> None: - """Run configuration test for mode register.""" + """Run configuration test for HVAC mode register.""" state = hass.states.get(ENTITY_ID) assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] assert HVACMode.HEAT in state.attributes[ATTR_HVAC_MODES] @@ -196,6 +213,47 @@ async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> No assert HVACMode.FAN_ONLY in state.attributes[ATTR_HVAC_MODES] +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_OFF: 1, + CONF_FAN_MODE_AUTO: 2, + CONF_FAN_MODE_LOW: 3, + CONF_FAN_MODE_MEDIUM: 4, + CONF_FAN_MODE_HIGH: 5, + }, + }, + } + ], + }, + ], +) +async def test_config_fan_mode_register(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for Fan mode register.""" + state = hass.states.get(ENTITY_ID) + assert FAN_ON in state.attributes[ATTR_FAN_MODES] + assert FAN_OFF in state.attributes[ATTR_FAN_MODES] + assert FAN_AUTO in state.attributes[ATTR_FAN_MODES] + assert FAN_LOW in state.attributes[ATTR_FAN_MODES] + assert FAN_MEDIUM in state.attributes[ATTR_FAN_MODES] + assert FAN_HIGH in state.attributes[ATTR_FAN_MODES] + assert FAN_TOP not in state.attributes[ATTR_FAN_MODES] + assert FAN_MIDDLE not in state.attributes[ATTR_FAN_MODES] + assert FAN_DIFFUSE not in state.attributes[ATTR_FAN_MODES] + assert FAN_FOCUS not in state.attributes[ATTR_FAN_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -341,6 +399,96 @@ async def test_service_climate_update( assert hass.states.get(ENTITY_ID).state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_LOW, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_MEDIUM, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + }, + ] + }, + FAN_HIGH, + [0x02], + ), + ], +) +async def test_service_climate_fan_update( + hass: HomeAssistant, mock_modbus, mock_ha, result, register_words +) -> None: + """Run test for service homeassistant.update_entity.""" + mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).attributes[ATTR_FAN_MODE] == result + + @pytest.mark.parametrize( ("temperature", "result", "do_config"), [ @@ -532,10 +680,10 @@ async def test_service_climate_set_temperature( ), ], ) -async def test_service_set_mode( +async def test_service_set_hvac_mode( hass: HomeAssistant, hvac_mode, result, mock_modbus, mock_ha ) -> None: - """Test set mode.""" + """Test set HVAC mode.""" mock_modbus.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, @@ -548,6 +696,69 @@ async def test_service_set_mode( ) +@pytest.mark.parametrize( + ("fan_mode", "result", "do_config"), + [ + ( + FAN_OFF, + [0x02], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ( + FAN_ON, + [0x01], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ], +) +async def test_service_set_fan_mode( + hass: HomeAssistant, fan_mode, result, mock_modbus, mock_ha +) -> None: + """Test set Fan mode.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_fan_mode", + { + "entity_id": ENTITY_ID, + ATTR_FAN_MODE: fan_mode, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} @@ -581,46 +792,6 @@ async def test_restore_state_climate( assert state.attributes[ATTR_TEMPERATURE] == 37 -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_CLIMATES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_LAZY_ERROR: 1, - } - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x8000], - True, - "17", - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_climate( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index b91b38b1f701e4..39897822bc8b81 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -1,5 +1,4 @@ """The tests for the Modbus cover component.""" -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -9,7 +8,6 @@ CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OPEN, @@ -33,7 +31,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{COVER_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") ENTITY_ID2 = f"{ENTITY_ID}_2" @@ -59,7 +57,6 @@ CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -71,7 +68,6 @@ CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -127,45 +123,6 @@ async def test_coil_cover(hass: HomeAssistant, expected, mock_do_cycle) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_COVERS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OPEN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_cover( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 932e07b2d1a0ed..0922329d4b75e3 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -11,7 +11,6 @@ CONF_DEVICE_ADDRESS, CONF_FANS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -66,7 +65,6 @@ CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, @@ -84,7 +82,6 @@ CONF_DEVICE_ADDRESS: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index e66115f24d9c6b..df41580711903b 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,9 +40,19 @@ CALL_TYPE_WRITE_REGISTERS, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_CLIMATES, CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_COOL, + CONF_HVAC_MODE_HEAT, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, @@ -53,6 +63,7 @@ CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, @@ -68,6 +79,7 @@ ) from homeassistant.components.modbus.validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -361,6 +373,25 @@ async def test_duplicate_modbus_validator(do_config) -> None: assert len(do_config) == 1 +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 7, + CONF_FAN_MODE_OFF: 9, + CONF_FAN_MODE_HIGH: 9, + }, + } + ], +) +async def test_duplicate_fan_mode_validator(do_config) -> None: + """Test duplicate modbus validator.""" + duplicate_fan_mode_validator(do_config) + assert len(do_config[CONF_FAN_MODE_VALUES]) == 2 + + @pytest.mark.parametrize( "do_config", [ @@ -404,12 +435,170 @@ async def test_duplicate_modbus_validator(do_config) -> None: ], } ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], ], ) async def test_duplicate_entity_validator(do_config) -> None: """Test duplicate entity validator.""" duplicate_entity_validator(do_config) - assert len(do_config[0][CONF_SENSORS]) == 1 + if CONF_SENSORS in do_config[0]: + assert len(do_config[0][CONF_SENSORS]) == 1 + elif CONF_CLIMATES in do_config[0]: + assert len(do_config[0][CONF_CLIMATES]) == 1 + + +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 119, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 99, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 117, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 121, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + ], +) +async def test_duplicate_entity_validator_with_climate(do_config) -> None: + """Test duplicate entity validator.""" + duplicate_entity_validator(do_config) + assert len(do_config[0][CONF_CLIMATES]) == 1 @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 1d6963aaa12eaf..ecd9abd71b8201 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -10,7 +10,6 @@ CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -55,7 +54,6 @@ CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 1c627faa09ce69..8fb7f9fd9519c1 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,7 +1,6 @@ """The tests for the Modbus sensor component.""" import struct -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.modbus.const import ( @@ -10,7 +9,6 @@ CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_NAN_VALUE, @@ -19,7 +17,6 @@ CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, @@ -50,7 +47,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import mock_restore_cache_with_extra_data @@ -81,7 +78,6 @@ CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -98,7 +94,6 @@ CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -125,7 +120,6 @@ CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, } ] }, @@ -228,7 +222,6 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: CONF_ADDRESS: 1234, CONF_DATA_TYPE: DataType.CUSTOM, CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "invalid", }, ] @@ -247,7 +240,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - f"{TEST_ENTITY_NAME}: `{CONF_STRUCTURE}:` missing, 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 +269,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}`", ), ], ) @@ -555,7 +548,6 @@ async def test_config_wrong_struct_sensor( ( { CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, }, [0x0102], False, @@ -869,9 +861,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}" @@ -1145,41 +1138,6 @@ async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 1, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception"), - [ - ( - [0x8000], - True, - ), - ], -) -async def test_lazy_error_sensor( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == "17" - await do_next_cycle(hass, mock_do_cycle, 5) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - - @pytest.mark.parametrize( "do_config", [ @@ -1289,7 +1247,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No [ ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT16, }, [0x0102], @@ -1305,7 +1262,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT32, }, [0x0102, 0x0304], diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 0eb40d2c08299e..28c44440581c0c 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -2,7 +2,6 @@ from datetime import timedelta from unittest import mock -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -13,7 +12,6 @@ CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -39,7 +37,7 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import async_fire_time_changed @@ -64,7 +62,6 @@ CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, @@ -227,46 +224,6 @@ async def test_all_switch(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SWITCHES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - CONF_VERIFY: {}, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OFF, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_switch( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "mock_test_state", [(State(ENTITY_ID, STATE_ON),), (State(ENTITY_ID, STATE_OFF),)], diff --git a/tests/components/modern_forms/test_binary_sensor.py b/tests/components/modern_forms/test_binary_sensor.py index 6b64beb4f1a6ae..3ea0fca99d5c74 100644 --- a/tests/components/modern_forms/test_binary_sensor.py +++ b/tests/components/modern_forms/test_binary_sensor.py @@ -11,20 +11,20 @@ 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 12083bb5ab6d98..9dc5ca9960fde0 100644 --- a/tests/components/modern_forms/test_fan.py +++ b/tests/components/modern_forms/test_fan.py @@ -35,13 +35,13 @@ 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 b989f0f9ef3f58..9befb36d00dab8 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 7e5b5e824f2f13..080290944b248c 100644 --- a/tests/components/modern_forms/test_light.py +++ b/tests/components/modern_forms/test_light.py @@ -28,13 +28,13 @@ 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 7e3914cd7d9e9d..279942f39a9174 100644 --- a/tests/components/modern_forms/test_sensor.py +++ b/tests/components/modern_forms/test_sensor.py @@ -4,7 +4,6 @@ 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 eae51d034f6121..b0ddc31150b702 100644 --- a/tests/components/modern_forms/test_switch.py +++ b/tests/components/modern_forms/test_switch.py @@ -22,13 +22,13 @@ 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 fb1c2ece1864f5..c2f9ef01111f5f 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 922febed3bfa19..38af8dcb91273d 100644 --- a/tests/components/moon/test_sensor.py +++ b/tests/components/moon/test_sensor.py @@ -39,6 +39,8 @@ ) 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 5f5c5f7854eb13..5af8d4139eb285 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 cb42e51f474775..6b90870c4da764 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 659738ef2c5910..0892c0dead02c5 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 cc193f5fb60933..a6fbcc49052d87 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 617f472ab4e50e..7c66645bb4464e 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -63,12 +63,13 @@ ) -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/motionmount/__init__.py b/tests/components/motionmount/__init__.py new file mode 100644 index 00000000000000..da6fbae32a3c3b --- /dev/null +++ b/tests/components/motionmount/__init__.py @@ -0,0 +1,42 @@ +"""Tests for the Vogel's MotionMount integration.""" + +from ipaddress import ip_address + +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_PORT + +HOST = "192.168.1.31" +PORT = 23 + +TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local." + +ZEROCONF_NAME = "My MotionMount" +ZEROCONF_HOST = HOST +ZEROCONF_HOSTNAME = "MMF8A55F.local." +ZEROCONF_PORT = PORT +ZEROCONF_MAC = "c4:dd:57:f8:a5:5f" + +MOCK_USER_INPUT = { + CONF_HOST: HOST, + CONF_PORT: PORT, +} + +MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( + type=TVM_ZEROCONF_SERVICE_TYPE, + name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], + hostname=ZEROCONF_HOSTNAME, + port=ZEROCONF_PORT, + properties={"txtvers": "1", "model": "TVM 7675"}, +) + +MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = zeroconf.ZeroconfServiceInfo( + type=TVM_ZEROCONF_SERVICE_TYPE, + name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], + hostname=ZEROCONF_HOSTNAME, + port=ZEROCONF_PORT, + properties={"mac": ZEROCONF_MAC, "txtvers": "2", "model": "TVM 7675"}, +) diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py new file mode 100644 index 00000000000000..8a838dac83ccb9 --- /dev/null +++ b/tests/components/motionmount/conftest.py @@ -0,0 +1,44 @@ +"""Fixtures for Vogel's MotionMount integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.motionmount.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=ZEROCONF_NAME, + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=ZEROCONF_MAC, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.motionmount.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_motionmount_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked MotionMount config flow.""" + + with patch( + "homeassistant.components.motionmount.config_flow.motionmount.MotionMount", + autospec=True, + ) as motionmount_mock: + client = motionmount_mock.return_value + yield client diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py new file mode 100644 index 00000000000000..aa7ea73b5775ac --- /dev/null +++ b/tests/components/motionmount/test_config_flow.py @@ -0,0 +1,488 @@ +"""Tests for the Vogel's MotionMount config flow.""" +import dataclasses +import socket +from unittest.mock import MagicMock, PropertyMock + +import motionmount +import pytest + +from homeassistant.components.motionmount.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + HOST, + MOCK_USER_INPUT, + MOCK_ZEROCONF_TVM_SERVICE_INFO_V1, + MOCK_ZEROCONF_TVM_SERVICE_INFO_V2, + PORT, + ZEROCONF_HOSTNAME, + ZEROCONF_MAC, + ZEROCONF_NAME, +) + +from tests.common import MockConfigEntry + +MAC = bytes.fromhex("c4dd57f8a55f") +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +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={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + +async def test_user_connection_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_connection_error_invalid_hostname( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when an invalid hostname is provided.""" + mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_timeout_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a timeout error.""" + mock_motionmount_config_flow.connect.side_effect = TimeoutError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "time_out" + + +async def test_user_not_connected_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a not connected error.""" + mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_connected" + + +async def test_user_response_error_single_device_old_ce_old_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + + +async def test_user_response_error_single_device_new_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock( + return_value=b"\x00\x00\x00\x00\x00\x00" + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + + +async def test_user_response_error_single_device_new_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_user_response_error_multi_device_old_ce_old_new_pro( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there are multiple devices.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_response_error_multi_device_new_ce_new_pro( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there are multiple devices.""" + mock_config_entry.add_to_hass(hass) + + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_connection_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_connection_error_invalid_hostname( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_timout_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a timeout error.""" + mock_motionmount_config_flow.connect.side_effect = TimeoutError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "time_out" + + +async def test_zeroconf_not_connected_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a not connected error.""" + mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_connected" + + +async def test_show_zeroconf_form_old_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_old_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_new_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + type(mock_motionmount_config_flow).mac = PropertyMock( + return_value=b"\x00\x00\x00\x00\x00\x00" + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_new_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_zeroconf_device_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test we abort zeroconf flow if device already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test the full manual user flow from start to finish.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_full_zeroconf_flow_implementation( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test the full manual user flow from start to finish.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 6d6c74753661d1..9bb5c8b2585d51 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -33,6 +33,7 @@ ) from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .test_common import ( help_custom_config, @@ -1130,8 +1131,9 @@ async def test_set_preset_mode_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "comfort" - await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) - assert "'invalid' is not a valid preset mode" in caplog.text + with pytest.raises(ServiceValidationError): + await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) + assert "'invalid' is not a valid preset mode" in caplog.text @pytest.mark.parametrize( @@ -1187,8 +1189,9 @@ async def test_set_preset_mode_explicit_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "comfort" - await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) - assert "'invalid' is not a valid preset mode" in caplog.text + with pytest.raises(ServiceValidationError): + await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) + assert "'invalid' is not a valid preset mode" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 0664f6e8d6f2c1..cb5ff53d7e905c 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -8,6 +8,7 @@ from typing import Any from unittest.mock import ANY, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol import yaml @@ -31,6 +32,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @@ -1320,9 +1322,8 @@ async def help_test_entity_debug_info_max_messages( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(start_dt): for i in range(0, debug_info.STORED_MESSAGES + 1): async_fire_mqtt_message(hass, "test-topic", f"{i}") @@ -1396,7 +1397,7 @@ async def help_test_entity_debug_info_message( debug_info_data = debug_info.info_for_device(hass, device.id) - start_dt = datetime(2019, 1, 1, 0, 0, 0) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) if state_topic is not None: assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 @@ -1404,8 +1405,7 @@ async def help_test_entity_debug_info_message( "subscriptions" ] - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + with freeze_time(start_dt): async_fire_mqtt_message(hass, str(state_topic), state_payload) debug_info_data = debug_info.info_for_device(hass, device.id) @@ -1426,8 +1426,7 @@ async def help_test_entity_debug_info_message( expected_transmissions = [] if service: # Trigger an outgoing MQTT message - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + with freeze_time(start_dt): if service: service_data = {ATTR_ENTITY_ID: f"{domain}.beer_test"} if service_parameters: diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index f3bf92951b091a..df7b7a64b3dba8 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1,4 +1,5 @@ """The tests for the MQTT cover platform.""" +from copy import deepcopy from typing import Any from unittest.mock import patch @@ -22,7 +23,6 @@ CONF_TILT_STATUS_TEMPLATE, CONF_TILT_STATUS_TOPIC, MQTT_COVER_ATTRIBUTES_BLOCKED, - MqttCover, ) from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -196,8 +196,21 @@ async def test_opening_and_closing_state_via_custom_state_payload( } ], ) +@pytest.mark.parametrize( + ("position", "assert_state"), + [ + (0, STATE_CLOSED), + (1, STATE_OPEN), + (30, STATE_OPEN), + (99, STATE_OPEN), + (100, STATE_OPEN), + ], +) async def test_open_closed_state_from_position_optimistic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + assert_state: str, ) -> None: """Test the state after setting the position using optimistic mode.""" await mqtt_mock_entry() @@ -208,24 +221,201 @@ async def test_open_closed_state_from_position_optimistic( await hass.services.async_call( cover.DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: 0}, + {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: position}, blocking=True, ) state = hass.states.get("cover.test") - assert state.state == STATE_CLOSED + assert state.state == assert_state assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_POSITION) == position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "position_topic": "position-topic", + "set_position_topic": "set-position-topic", + "qos": 0, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + "optimistic": True, + "position_closed": 10, + "position_open": 90, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "assert_state"), + [ + (0, STATE_CLOSED), + (1, STATE_CLOSED), + (10, STATE_CLOSED), + (11, STATE_OPEN), + (30, STATE_OPEN), + (99, STATE_OPEN), + (100, STATE_OPEN), + ], +) +async def test_open_closed_state_from_position_optimistic_alt_positions( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + assert_state: str, +) -> None: + """Test the state after setting the position. + + Test with alt opened and closed positions using optimistic mode. + """ + await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN await hass.services.async_call( cover.DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: 100}, + {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: position}, blocking=True, ) state = hass.states.get("cover.test") - assert state.state == STATE_OPEN + assert state.state == assert_state assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_POSITION) == position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "tilt_command_topic": "set-position-topic", + "qos": 0, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + "optimistic": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("tilt_position", "tilt_toggled_position"), + [(0, 100), (1, 0), (99, 0), (100, 0)], +) +async def test_tilt_open_closed_toggle_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + tilt_position: int, + tilt_toggled_position: int, +) -> None: + """Test the tilt state after setting and toggling the tilt position. + + Test opened and closed tilt positions using optimistic mode. + """ + await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: tilt_position}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_position + + # toggle cover tilt + await hass.services.async_call( + cover.DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_toggled_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "tilt_command_topic": "set-position-topic", + "qos": 0, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + "optimistic": True, + "tilt_min": 5, + "tilt_max": 95, + "tilt_closed_value": 15, + "tilt_opened_value": 85, + } + } + } + ], +) +@pytest.mark.parametrize( + ("tilt_position", "tilt_toggled_position"), + [(0, 88), (11, 88), (12, 11), (30, 11), (90, 11), (100, 11)], +) +async def test_tilt_open_closed_toggle_optimistic_alt_positions( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + tilt_position: int, + tilt_toggled_position: int, +) -> None: + """Test the tilt state after setting and toggling the tilt position. + + Test with alt opened and closed tilt positions using optimistic mode. + """ + await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: tilt_position}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_position + + # toggle cover tilt + await hass.services.async_call( + cover.DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_toggled_position @pytest.mark.parametrize( @@ -2235,350 +2425,6 @@ async def test_tilt_position_altered_range( ) -async def test_find_percentage_in_range_defaults(hass: HomeAssistant) -> None: - """Test find percentage in range with default range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 100, - "position_closed": 0, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 0, - "tilt_max": 100, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(44) == 44 - assert mqtt_cover.find_percentage_in_range(44, "cover") == 44 - - -async def test_find_percentage_in_range_altered(hass: HomeAssistant) -> None: - """Test find percentage in range with altered range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 180, - "position_closed": 80, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 80, - "tilt_max": 180, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(120) == 40 - assert mqtt_cover.find_percentage_in_range(120, "cover") == 40 - - -async def test_find_percentage_in_range_defaults_inverted(hass: HomeAssistant) -> None: - """Test find percentage in range with default range but inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 0, - "position_closed": 100, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 100, - "tilt_max": 0, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(44) == 56 - assert mqtt_cover.find_percentage_in_range(44, "cover") == 56 - - -async def test_find_percentage_in_range_altered_inverted(hass: HomeAssistant) -> None: - """Test find percentage in range with altered range and inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 80, - "position_closed": 180, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 180, - "tilt_max": 80, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(120) == 60 - assert mqtt_cover.find_percentage_in_range(120, "cover") == 60 - - -async def test_find_in_range_defaults(hass: HomeAssistant) -> None: - """Test find in range with default range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 100, - "position_closed": 0, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 0, - "tilt_max": 100, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(44) == 44 - assert mqtt_cover.find_in_range_from_percent(44, "cover") == 44 - - -async def test_find_in_range_altered(hass: HomeAssistant) -> None: - """Test find in range with altered range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 180, - "position_closed": 80, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 80, - "tilt_max": 180, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(40) == 120 - assert mqtt_cover.find_in_range_from_percent(40, "cover") == 120 - - -async def test_find_in_range_defaults_inverted(hass: HomeAssistant) -> None: - """Test find in range with default range but inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 0, - "position_closed": 100, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 100, - "tilt_max": 0, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(56) == 44 - assert mqtt_cover.find_in_range_from_percent(56, "cover") == 44 - - -async def test_find_in_range_altered_inverted(hass: HomeAssistant) -> None: - """Test find in range with altered range and inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 80, - "position_closed": 180, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 180, - "tilt_max": 80, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(60) == 120 - assert mqtt_cover.find_in_range_from_percent(60, "cover") == 120 - - @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -3347,6 +3193,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", @@ -3577,7 +3428,7 @@ async def test_publishing_with_custom_encoding( ) -> None: """Test publishing MQTT payload with different encoding.""" domain = cover.DOMAIN - config = DEFAULT_CONFIG + config = deepcopy(DEFAULT_CONFIG) config[mqtt.DOMAIN][domain]["position_topic"] = "some-position-topic" await help_test_publishing_with_custom_encoding( diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 485c2774f7b4d9..90360bf7e3fb58 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -973,11 +973,12 @@ def callback(trigger): 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 ed01b70e660b4f..017d24a39ce0c8 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -34,7 +34,8 @@ 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,7 +1523,7 @@ async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: """Test mqtt step.""" return self.async_abort(reason="already_configured") - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + 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 @@ -1552,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 @@ -1575,7 +1576,7 @@ async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: """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 4c0e63fec1f7cf..1a75d61c733c6a 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -88,6 +88,68 @@ async def test_setting_event_value_via_mqtt_message( assert state.attributes.get("duration") == "short" +@pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_multiple_events_are_all_updating_the_state( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test all events are respected and trigger a state write.""" + await mqtt_mock_entry() + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + async_fire_mqtt_message( + hass, "test-topic", '{"event_type": "press", "duration": "short" }' + ) + assert len(mock_async_ha_write_state.mock_calls) == 1 + async_fire_mqtt_message( + hass, "test-topic", '{"event_type": "press", "duration": "short" }' + ) + assert len(mock_async_ha_write_state.mock_calls) == 2 + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_handling_retained_event_payloads( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test if event messages with a retained flag are ignored.""" + await mqtt_mock_entry() + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=True, + ) + assert len(mock_async_ha_write_state.mock_calls) == 0 + + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=False, + ) + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=True, + ) + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=False, + ) + assert len(mock_async_ha_write_state.mock_calls) == 2 + + @pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @pytest.mark.parametrize( @@ -500,14 +562,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 +590,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 21d3bcce3a97de..e7c4eba54e21fc 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 5bb866623224c6..98e2c9b71fe4a8 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -8,6 +8,7 @@ from typing import Any, TypedDict from unittest.mock import ANY, MagicMock, call, mock_open, patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -40,6 +41,7 @@ from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.dt import utcnow from .test_common import help_all_subscribe_calls @@ -3050,7 +3052,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() @@ -3097,8 +3101,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"] @@ -3109,7 +3111,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) @@ -3132,7 +3134,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() @@ -3179,8 +3183,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"] @@ -3190,7 +3192,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 @@ -3253,7 +3255,10 @@ 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, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3264,13 +3269,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) @@ -3279,10 +3282,9 @@ async def test_debug_info_wildcard( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/abc", "123") + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/abc", "123") debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 @@ -3301,7 +3303,10 @@ 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, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info removes messages with same timestamp.""" await mqtt_mock_entry() @@ -3312,13 +3317,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) @@ -3327,14 +3330,13 @@ async def test_debug_info_filter_same( "subscriptions" ] - dt1 = datetime(2019, 1, 1, 0, 0, 0) - dt2 = datetime(2019, 1, 1, 0, 0, 1) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = dt1 - async_fire_mqtt_message(hass, "sensor/abc", "123") - async_fire_mqtt_message(hass, "sensor/abc", "123") - dt_utcnow.return_value = dt2 - async_fire_mqtt_message(hass, "sensor/abc", "123") + dt1 = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + dt2 = datetime(2019, 1, 1, 0, 0, 1, tzinfo=dt_util.UTC) + freezer.move_to(dt1) + async_fire_mqtt_message(hass, "sensor/abc", "123") + async_fire_mqtt_message(hass, "sensor/abc", "123") + freezer.move_to(dt2) + async_fire_mqtt_message(hass, "sensor/abc", "123") debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 @@ -3361,7 +3363,10 @@ 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, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3373,13 +3378,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) @@ -3388,10 +3391,9 @@ async def test_debug_info_same_topic( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 @@ -3408,14 +3410,16 @@ async def test_debug_info_same_topic( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) async def test_debug_info_qos_retain( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3426,13 +3430,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) @@ -3441,19 +3443,18 @@ async def test_debug_info_qos_retain( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - # simulate the first message was replayed from the broker with retained flag - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) - # simpulate someone else subscribed and retained messages were replayed - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + # simulate the first message was replayed from the broker with retained flag + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) + # simpulate someone else subscribed and retained messages were replayed + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index e7471829856a19..c5c24c3ae7991e 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -725,6 +725,93 @@ async def test_controlling_state_via_topic2( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light_rgb/set", + "state_topic": "test_light_rgb/set", + "rgb": True, + "color_temp": True, + "brightness": True, + } + } + } + ], +) +async def test_controlling_the_state_with_legacy_color_handling( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test state updates for lights with a legacy color handling.""" + supported_color_modes = ["color_temp", "hs"] + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_mode") is None + assert state.attributes.get("color_temp") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") is None + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("supported_color_modes") == supported_color_modes + assert state.attributes.get("xy_color") is None + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + for _ in range(0, 2): + # Returned state after the light was turned on + # Receiving legacy color mode: rgb. + async_fire_mqtt_message( + hass, + "test_light_rgb/set", + '{ "state": "ON", "brightness": 255, "level": 100, "hue": 16,' + '"saturation": 100, "color": { "r": 255, "g": 67, "b": 0 }, ' + '"bulb_mode": "color", "color_mode": "rgb" }', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_mode") == "hs" + assert state.attributes.get("color_temp") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") == (15.765, 100.0) + assert state.attributes.get("rgb_color") == (255, 67, 0) + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("xy_color") == (0.674, 0.322) + + # Returned state after the lights color mode was changed + # Receiving legacy color mode: color_temp + async_fire_mqtt_message( + hass, + "test_light_rgb/set", + '{ "state": "ON", "brightness": 255, "level": 100, ' + '"kelvin": 92, "color_temp": 353, "bulb_mode": "white", ' + '"color_mode": "color_temp" }', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_mode") == "color_temp" + assert state.attributes.get("color_temp") == 353 + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") == (28.125, 61.661) + assert state.attributes.get("rgb_color") == (255, 171, 97) + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("xy_color") == (0.513, 0.386) + + @pytest.mark.parametrize( "hass_config", [ @@ -1785,6 +1872,24 @@ async def test_brightness_scale( assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 + # Turn on the light with half brightness + async_fire_mqtt_message( + hass, "test_light_bright_scale", '{"state":"ON", "brightness": 50}' + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 129 + + # Test limmiting max brightness + async_fire_mqtt_message( + hass, "test_light_bright_scale", '{"state":"ON", "brightness": 103}' + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + @pytest.mark.parametrize( "hass_config", @@ -1844,7 +1949,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 1ca9bf07d72a6b..7a625a2f5f63e9 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -312,6 +312,7 @@ def test_callback(event) -> None: @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_select.py b/tests/components/mqtt/test_select.py index 0c18881d86e2fd..030f5a2ac9ade0 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -707,7 +707,7 @@ async def test_publishing_with_custom_encoding( ) -> None: """Test publishing MQTT payload with different encoding.""" domain = select.DOMAIN - config = DEFAULT_CONFIG + config = copy.deepcopy(DEFAULT_CONFIG) config[mqtt.DOMAIN][domain]["options"] = ["milk", "beer"] await help_test_publishing_with_custom_encoding( diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 0f1be02875c2b9..e33d626c5d8691 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 55eac636edb40b..0476c880b1a764 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/test_valve.py b/tests/components/mqtt/test_valve.py new file mode 100644 index 00000000000000..e37b52f56fbf87 --- /dev/null +++ b/tests/components/mqtt/test_valve.py @@ -0,0 +1,1507 @@ +"""The tests for the MQTT valve platform.""" +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, valve +from homeassistant.components.mqtt.valve import ( + MQTT_VALVE_ATTRIBUTES_BLOCKED, + ValveEntityFeature, +) +from homeassistant.components.valve import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + SERVICE_SET_VALVE_POSITION, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_STOP_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant + +from .test_common import ( + help_custom_config, + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + valve.DOMAIN: { + "command_topic": "command-topic", + "state_topic": "test-topic", + "name": "test", + } + } +} + +DEFAULT_CONFIG_REPORTS_POSITION = { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "state_topic": "test-topic", + "reports_position": True, + } + } +} + + +@pytest.fixture(autouse=True) +def valve_platform_only(): + """Only setup the valve platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.VALVE]): + yield + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ("open", STATE_OPEN), + ("closed", STATE_CLOSED), + ("closing", STATE_CLOSING), + ("opening", STATE_OPENING), + ('{"state" : "open"}', STATE_OPEN), + ('{"state" : "closed"}', STATE_CLOSED), + ('{"state" : "closing"}', STATE_CLOSING), + ('{"state" : "opening"}', STATE_OPENING), + ], +) +async def test_state_via_state_topic_no_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic without position and without template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "value_template": "{{ value_json.state }}", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ('{"state":"open"}', STATE_OPEN), + ('{"state":"closed"}', STATE_CLOSED), + ('{"state":"closing"}', STATE_CLOSING), + ('{"state":"opening"}', STATE_OPENING), + ], +) +async def test_state_via_state_topic_with_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic with template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "value_template": "{{ value_json.position }}", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ('{"position":100}', STATE_OPEN), + ('{"position":50.0}', STATE_OPEN), + ('{"position":0}', STATE_CLOSED), + ('{"position":"non_numeric"}', STATE_UNKNOWN), + ('{"ignored":12}', STATE_UNKNOWN), + ], +) +async def test_state_via_state_topic_with_position_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic with position template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state", "valve_position"), + [ + ("invalid", STATE_UNKNOWN, None), + ("0", STATE_CLOSED, 0), + ("opening", STATE_OPENING, None), + ("50", STATE_OPEN, 50), + ("closing", STATE_CLOSING, None), + ("100", STATE_OPEN, 100), + ("open", STATE_UNKNOWN, None), + ("closed", STATE_UNKNOWN, None), + ("-10", STATE_CLOSED, 0), + ("110", STATE_OPEN, 100), + ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), + ('{"position": 10, "state": "opening"}', STATE_OPENING, 10), + ('{"position": 50, "state": "open"}', STATE_OPEN, 50), + ('{"position": 100, "state": "closing"}', STATE_CLOSING, 100), + ('{"position": 90, "state": "closing"}', STATE_CLOSING, 90), + ('{"position": 0, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": -10, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": 110, "state": "open"}', STATE_OPEN, 100), + ], +) +async def test_state_via_state_topic_through_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, + valve_position: int | None, +) -> None: + """Test the controlling state via topic through position. + + Test is still possible to process a `opening` or `closing` state update. + Additional we test json messages can be processed containing both position and state. + Incoming rendered positions are clamped between 0..100. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + } + } + } + ], +) +async def test_opening_closing_state_is_reset( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the controlling state via topic through position. + + Test a `opening` or `closing` state update is reset correctly after sequential updates. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + messages = [ + ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), + ('{"position": 50, "state": "opening"}', STATE_OPENING, 50), + ('{"position": 60}', STATE_OPENING, 60), + ('{"position": 100, "state": "opening"}', STATE_OPENING, 100), + ('{"position": 100, "state": null}', STATE_OPEN, 100), + ('{"position": 90, "state": "closing"}', STATE_CLOSING, 90), + ('{"position": 40}', STATE_CLOSING, 40), + ('{"position": 0}', STATE_CLOSED, 0), + ('{"position": 10}', STATE_OPEN, 10), + ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), + ('{"position": 0, "state": "closing"}', STATE_CLOSING, 0), + ('{"position": 0}', STATE_CLOSED, 0), + ] + + for message, asserted_state, valve_position in messages: + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + ("hass_config", "message", "err_message"), + [ + ( + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": False, + } + } + }, + '{"position": 0}', + "Missing required `state` attribute in json payload", + ), + ( + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + } + } + }, + '{"state": "opening"}', + "Missing required `position` attribute in json payload", + ), + ], +) +async def test_invalid_state_updates( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + message: str, + err_message: str, +) -> None: + """Test the controlling state via topic through position. + + Test a `opening` or `closing` state update is reset correctly after sequential updates. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + state = hass.states.get("valve.test") + assert err_message in caplog.text + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state", "valve_position"), + [ + ("-128", STATE_CLOSED, 0), + ("0", STATE_OPEN, 50), + ("127", STATE_OPEN, 100), + ("-130", STATE_CLOSED, 0), + ("130", STATE_OPEN, 100), + ('{"position": -128, "state": "opening"}', STATE_OPENING, 0), + ('{"position": -30, "state": "opening"}', STATE_OPENING, 38), + ('{"position": 30, "state": "open"}', STATE_OPEN, 61), + ('{"position": 127, "state": "closing"}', STATE_CLOSING, 100), + ('{"position": 100, "state": "closing"}', STATE_CLOSING, 89), + ('{"position": -128, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": -130, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": 130, "state": "open"}', STATE_OPEN, 100), + ], +) +async def test_state_via_state_trough_position_with_alt_range( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, + valve_position: int | None, +) -> None: + """Test the controlling state via topic through position and an alternative range. + + Test is still possible to process a `opening` or `closing` state update. + Additional we test json messages can be processed containing both position and state. + Incoming rendered positions are clamped between 0..100. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "SToP", + "payload_open": "OPeN", + "payload_close": "CLOsE", + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "CLOsE"), + (SERVICE_OPEN_VALVE, "OPeN"), + (SERVICE_STOP_VALVE, "SToP"), + ], +) +async def tests_controling_valve_by_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve by state.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("hass_config", "supported_features"), + [ + (DEFAULT_CONFIG, ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": "OPEN", "payload_close": "CLOSE"},), + ), + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": "OPEN", "payload_close": None},), + ), + ValveEntityFeature.OPEN, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": None, "payload_close": "CLOSE"},), + ), + ValveEntityFeature.CLOSE, + ), + ( + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG, ({"payload_stop": "STOP"},) + ), + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG_REPORTS_POSITION, + ({"payload_stop": "STOP"},), + ), + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION, + ), + ], +) +async def tests_supported_features( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + supported_features: ValveEntityFeature, +) -> None: + """Test the valve's supported features.""" + assert await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state is not None + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == supported_features + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"payload_open": "OPEN"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"payload_close": "CLOSE"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"state_open": "open"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"state_closed": "closed"},) + ), + ], +) +async def tests_open_close_payload_config_not_allowed( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test open or close payload configs fail if valve reports position.""" + assert await mqtt_mock_entry() + + assert hass.states.get("valve.test") is None + + assert ( + "Options `payload_open`, `payload_close`, `state_open` and " + "`state_closed` are not allowed if the valve reports a position." in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "STOP", + "optimistic": True, + } + } + }, + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_stop": "STOP", + } + } + }, + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message", "asserted_state"), + [ + (SERVICE_CLOSE_VALVE, "CLOSE", STATE_CLOSED), + (SERVICE_OPEN_VALVE, "OPEN", STATE_OPEN), + ], +) +async def tests_controling_valve_by_state_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, + asserted_state: str, +) -> None: + """Test controlling a valve by state explicit and implicit optimistic.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "0"), + (SERVICE_OPEN_VALVE, "100"), + (SERVICE_STOP_VALVE, "-1"), + ], +) +async def tests_controling_valve_by_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message"), + [ + (0, "0"), + (30, "30"), + (100, "100"), + ], +) +async def tests_controling_valve_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, +) -> None: + """Test controlling a valve by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "optimistic": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message", "asserted_position", "asserted_state"), + [ + (0, "0", 0, STATE_CLOSED), + (30, "30", 30, STATE_OPEN), + (100, "100", 100, STATE_OPEN), + ], +) +async def tests_controling_valve_optimistic_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, + asserted_position: int, + asserted_state: str, +) -> None: + """Test controlling a valve optimistic by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == asserted_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message"), + [ + (0, "-128"), + (30, "-52"), + (80, "76"), + (100, "127"), + ], +) +async def tests_controling_valve_with_alt_range_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, +) -> None: + """Test controlling a valve with an alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "-128"), + (SERVICE_OPEN_VALVE, "127"), + ], +) +async def tests_controling_valve_with_alt_range_by_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve with an alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "STOP", + "optimistic": True, + "reports_position": True, + } + } + }, + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_stop": "STOP", + "reports_position": True, + } + } + }, + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message", "asserted_state", "asserted_position"), + [ + (SERVICE_CLOSE_VALVE, "0", STATE_CLOSED, 0), + (SERVICE_OPEN_VALVE, "100", STATE_OPEN, 100), + ], +) +async def tests_controling_valve_by_position_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, + asserted_state: str, + asserted_position: int, +) -> None: + """Test controlling a valve by state explicit and implicit optimistic.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_CURRENT_POSITION) is None + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes[ATTR_CURRENT_POSITION] == asserted_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "optimistic": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message", "asserted_position", "asserted_state"), + [ + (0, "-128", 0, STATE_CLOSED), + (30, "-52", 30, STATE_OPEN), + (50, "0", 50, STATE_OPEN), + (100, "127", 100, STATE_OPEN), + ], +) +async def tests_controling_valve_optimistic_alt_trange_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, + asserted_position: int, + asserted_state: str, +) -> None: + """Test controlling a valve optimistic and alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == asserted_position + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, valve.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "device_class": "water", + "state_topic": "test-topic", + } + } + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of a valid device class.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.attributes.get("device_class") == "water" + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "device_class": "abc123", + "state_topic": "test-topic", + } + } + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the setting of an invalid device class.""" + assert await mqtt_mock_entry() + assert "expected ValveDeviceClass" in caplog.text + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, + mqtt_mock_entry, + valve.DOMAIN, + DEFAULT_CONFIG, + MQTT_VALVE_ATTRIBUTES_BLOCKED, + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + valve.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + valve.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + valve.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique_id option only creates one valve per id.""" + await help_test_unique_id(hass, mqtt_mock_entry, valve.DOMAIN) + + +async def test_discovery_removal_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered valve.""" + data = '{ "name": "test", "command_topic": "test_topic" }' + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, valve.DOMAIN, data) + + +async def test_discovery_update_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered valve.""" + config1 = {"name": "Beer", "command_topic": "test_topic"} + config2 = {"name": "Milk", "command_topic": "test_topic"} + await help_test_discovery_update( + hass, mqtt_mock_entry, caplog, valve.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered valve.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.valve.MqttValve.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + valve.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, valve.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT valve device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT valve device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + valve.DOMAIN, + DEFAULT_CONFIG, + SERVICE_OPEN_VALVE, + command_payload="OPEN", + ) + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + SERVICE_OPEN_VALVE, + "command_topic", + None, + "OPEN", + None, + ), + ], +) +async def test_publishing_with_custom_encoding( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, +) -> None: + """Test publishing MQTT payload with different encoding.""" + domain = valve.DOMAIN + config = DEFAULT_CONFIG + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock_entry, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = valve.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("state_topic", "open", None, None), + ("state_topic", "closing", None, None), + ], +) +async def test_encoding_subscribable_topics( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, +) -> None: + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + valve.DOMAIN, + DEFAULT_CONFIG[mqtt.DOMAIN][valve.DOMAIN], + topic, + value, + attribute, + attribute_value, + skip_raw_test=True, + ) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = valve.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = valve.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "open", "closed"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt_room/test_sensor.py b/tests/components/mqtt_room/test_sensor.py index 72540f49ca793e..822e028f4f6086 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/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 883a94ea02e2c2..6df50f04ae2836 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -16,12 +16,12 @@ from homeassistant.components.mysensors.config_flow import DEFAULT_BAUD_RATE from homeassistant.components.mysensors.const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_SERIAL, CONF_VERSION, DOMAIN, ) +from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -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_config_flow.py b/tests/components/mysensors/test_config_flow.py index dc24a48edd416d..bff13d1604f440 100644 --- a/tests/components/mysensors/test_config_flow.py +++ b/tests/components/mysensors/test_config_flow.py @@ -9,7 +9,6 @@ from homeassistant import config_entries from homeassistant.components.mysensors.const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, @@ -23,6 +22,7 @@ DOMAIN, ConfGatewayType, ) +from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 9d1867b3158391..fd61e27a663fcd 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -15,6 +15,8 @@ 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/nam/test_button.py b/tests/components/nam/test_button.py index 4a1083874d0f67..ab4e46975f9a58 100644 --- a/tests/components/nam/test_button.py +++ b/tests/components/nam/test_button.py @@ -10,10 +10,8 @@ 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 dbd1c152d6b413..63034d5b0759d2 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 4f1b95ea2062e1..50cf3aba6593b9 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -35,11 +35,9 @@ 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_camera.py b/tests/components/nest/test_camera.py index 56c5bedaf0de9c..647a3419501135 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, Mock, patch import aiohttp +from freezegun import freeze_time from google_nest_sdm.event import EventMessage import pytest @@ -173,7 +174,7 @@ async def async_get_image(hass, width=None, height=None): async def fire_alarm(hass, point_in_time): """Fire an alarm and wait for callbacks to run.""" - with patch("homeassistant.util.dt.utcnow", return_value=point_in_time): + with freeze_time(point_in_time): async_fire_time_changed(hass, point_in_time) await hass.async_block_till_done() diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index c920eb5717d937..e1c3cc187db682 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -39,7 +39,7 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .common import ( DEVICE_COMMAND, @@ -1192,7 +1192,7 @@ async def test_thermostat_invalid_fan_mode( assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_fan_mode(hass, FAN_LOW) await hass.async_block_till_done() @@ -1474,7 +1474,7 @@ async def test_thermostat_invalid_set_preset_mode( assert thermostat.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_NONE] # Set preset mode that is invalid - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_preset_mode(hass, PRESET_SLEEP) await hass.async_block_till_done() diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 0776b80a3cdff2..61a7bc2354d390 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/fixtures/getstationsdata.json b/tests/components/netatmo/fixtures/getstationsdata.json index 10c3ca85e06c2c..b0da0820699889 100644 --- a/tests/components/netatmo/fixtures/getstationsdata.json +++ b/tests/components/netatmo/fixtures/getstationsdata.json @@ -475,22 +475,12 @@ "last_setup": 1558709954, "data_type": ["Temperature", "Humidity"], "battery_percent": 27, - "reachable": true, + "reachable": false, "firmware": 50, "last_message": 1644582699, "last_seen": 1644582699, "rf_status": 68, - "battery_vp": 4678, - "dashboard_data": { - "time_utc": 1644582648, - "Temperature": 9.4, - "Humidity": 57, - "min_temp": 6.7, - "max_temp": 9.8, - "date_max_temp": 1644534223, - "date_min_temp": 1644569369, - "temp_trend": "up" - } + "battery_vp": 4678 }, { "_id": "12:34:56:80:c1:ea", diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index bd9005bd389019..f1c54901445035 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -561,26 +561,28 @@ 'access_doorbell', 'access_presence', 'read_bubendorff', + 'read_bfi', 'read_camera', 'read_carbonmonoxidedetector', 'read_doorbell', 'read_homecoach', 'read_magellan', + 'read_mhs1', 'read_mx', 'read_presence', 'read_smarther', 'read_smokedetector', 'read_station', 'read_thermostat', - 'read_mhs1', 'write_bubendorff', + 'write_bfi', 'write_camera', 'write_magellan', + 'write_mhs1', 'write_mx', 'write_presence', 'write_smarther', 'write_thermostat', - 'write_mhs1', ]), 'type': 'Bearer', }), @@ -588,6 +590,7 @@ }), 'disabled_by': None, 'domain': 'netatmo', + 'minor_version': 1, 'options': dict({ 'weather_areas': dict({ 'Home avg': dict({ diff --git a/tests/components/netatmo/test_api.py b/tests/components/netatmo/test_api.py new file mode 100644 index 00000000000000..e2d495555c6ac4 --- /dev/null +++ b/tests/components/netatmo/test_api.py @@ -0,0 +1,22 @@ +"""The tests for the Netatmo api.""" + +from pyatmo.const import ALL_SCOPES + +from homeassistant.components import cloud +from homeassistant.components.netatmo import api +from homeassistant.components.netatmo.const import API_SCOPES_EXCLUDED_FROM_CLOUD + + +async def test_get_api_scopes_cloud() -> None: + """Test method to get API scopes when using cloud auth implementation.""" + result = api.get_api_scopes(cloud.DOMAIN) + + for scope in API_SCOPES_EXCLUDED_FROM_CLOUD: + assert scope not in result + + +async def test_get_api_scopes_other() -> None: + """Test method to get API scopes when using cloud auth implementation.""" + result = api.get_api_scopes("netatmo_239846i2f0j2") + + assert sorted(ALL_SCOPES) == result diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index e9a66cfefc818a..6dcc11d31abf1b 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -388,7 +388,7 @@ async def fake_post(*args, **kwargs): ), 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 fake_post_no_data(*args, **kwargs): ), 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 fake_post(*args, **kwargs): ), 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 99000403a38a5f..11e2077f8595bd 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -22,11 +22,18 @@ 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 +from homeassistant.exceptions import ServiceValidationError from homeassistant.util import dt as dt_util from .common import selected_platforms, simulate_webhook @@ -359,6 +366,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: @@ -676,15 +880,14 @@ async def test_service_preset_mode_invalid( await hass.async_block_till_done() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.cocina", ATTR_PRESET_MODE: "invalid"}, - blocking=True, - ) - await hass.async_block_till_done() - - assert "Preset mode 'invalid' not available" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.cocina", ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() async def test_valves_service_turn_off( diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index 0ece935abcbbe6..19f83830a4e358 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 e04295ae6684be..75b1e9e47e60d4 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 83218b6d6d1f57..b6df9191976e79 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -103,7 +103,7 @@ async def fake_post_request_no_data(*args, **kwargs): ), 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/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 00cec6f8aa087f..ce35873c3e5ce2 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -10,8 +10,8 @@ from .common import TEST_TIME, selected_platforms -async def test_weather_sensor(hass: HomeAssistant, config_entry, netatmo_auth) -> None: - """Test weather sensor setup.""" +async def test_indoor_sensor(hass: HomeAssistant, config_entry, netatmo_auth) -> None: + """Test indoor sensor setup.""" with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -25,6 +25,18 @@ async def test_weather_sensor(hass: HomeAssistant, config_entry, netatmo_auth) - assert hass.states.get(f"{prefix}pressure").state == "1014.5" +async def test_weather_sensor(hass: HomeAssistant, config_entry, netatmo_auth) -> None: + """Test weather sensor unreachable.""" + with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + prefix = "sensor.villa_outdoor_" + + assert hass.states.get(f"{prefix}temperature").state == "unavailable" + + async def test_public_weather_sensor( hass: HomeAssistant, config_entry, netatmo_auth ) -> None: diff --git a/tests/components/netgear_lte/__init__.py b/tests/components/netgear_lte/__init__.py new file mode 100644 index 00000000000000..6661c92312e0b4 --- /dev/null +++ b/tests/components/netgear_lte/__init__.py @@ -0,0 +1 @@ +"""Tests for the Netgear LTE component.""" diff --git a/tests/components/netgear_lte/conftest.py b/tests/components/netgear_lte/conftest.py new file mode 100644 index 00000000000000..e32034d660b9f9 --- /dev/null +++ b/tests/components/netgear_lte/conftest.py @@ -0,0 +1,85 @@ +"""Configure pytest for Netgear LTE tests.""" +from __future__ import annotations + +from aiohttp.client_exceptions import ClientError +import pytest + +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONTENT_TYPE_JSON +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +HOST = "192.168.5.1" +PASSWORD = "password" + +CONF_DATA = {CONF_HOST: HOST, CONF_PASSWORD: PASSWORD} + + +@pytest.fixture +def cannot_connect(aioclient_mock: AiohttpClientMocker) -> None: + """Mock cannot connect error.""" + aioclient_mock.get(f"http://{HOST}/model.json", exc=ClientError) + aioclient_mock.post(f"http://{HOST}/Forms/config", exc=ClientError) + + +@pytest.fixture +def unknown(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Netgear LTE unknown error.""" + aioclient_mock.get( + f"http://{HOST}/model.json", + text="something went wrong", + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture(name="connection") +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Netgear LTE connection.""" + aioclient_mock.get( + f"http://{HOST}/model.json", + text=load_fixture("netgear_lte/model.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.post( + f"http://{HOST}/Forms/config", + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.post( + f"http://{HOST}/Forms/smsSendMsg", + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create Netgear LTE entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, data=CONF_DATA, unique_id="FFFFFFFFFFFFF", title="Netgear LM1200" + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + connection: None, +) -> None: + """Set up the Netgear LTE integration in Home Assistant.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +@pytest.fixture(name="setup_cannot_connect") +async def setup_cannot_connect( + hass: HomeAssistant, + config_entry: MockConfigEntry, + cannot_connect: None, +) -> None: + """Set up the Netgear LTE integration in Home Assistant.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() diff --git a/tests/components/netgear_lte/fixtures/model.json b/tests/components/netgear_lte/fixtures/model.json new file mode 100644 index 00000000000000..c5f4a13f3afa81 --- /dev/null +++ b/tests/components/netgear_lte/fixtures/model.json @@ -0,0 +1,450 @@ +{ + "custom": { "AtTcpEnable": false, "end": 0 }, + "webd": { + "adminPassword": "****************", + "ownerModeEnabled": false, + "hideAdminPassword": true, + "end": "" + }, + "lcd": { "end": "" }, + "sim": { + "pin": { "mode": "Disabled", "retry": 3, "end": "" }, + "puk": { "retry": 10 }, + "mep": {}, + "phoneNumber": "(555) 555-5555", + "iccid": "1234567890123456789", + "imsi": "123456789012345", + "SPN": "", + "status": "Ready", + "end": "" + }, + "sms": { + "ready": true, + "sendEnabled": true, + "sendSupported": true, + "alertSupported": true, + "alertEnabled": false, + "alertNumList": "", + "alertCfgList": [ + { "category": "FWUpdate", "enabled": false }, + { "category": "DataUsageWarning", "enabled": false }, + { "category": "DataUsageExceeded", "enabled": false }, + { "category": "LTEFailoverLTE", "enabled": false }, + { "category": "LTEFailoverETH", "enabled": false }, + {} + ], + "unreadMsgs": 1, + "msgCount": 1, + "msgs": [ + { + "id": "1", + "rxTime": "20/01/23 03:39:35 PM", + "text": "text", + "sender": "889", + "read": false + }, + {} + ], + "trans": [{}], + "sendMsg": [ + { + "clientId": "eternalegypt.eternalegypt", + "enc": "Gsm7Bit", + "errorCode": 0, + "msgId": 1, + "receiver": "+15555555555", + "status": "Succeeded", + "text": "test SMS from Home Assistant", + "txTime": "1367252824" + }, + {} + ], + "end": "" + }, + "session": { + "userRole": "Admin", + "lang": "en", + "secToken": "secret" + }, + "general": { + "defaultLanguage": "en", + "PRIid": "12345678", + "genericResetStatus": "NotStarted", + "manufacturer": "Netgear", + "model": "LM1200", + "HWversion": "1.0", + "FWversion": "EC25AFFDR07A09M4G", + "appVersion": "NTG9X07C_20.06.09.00", + "buildDate": "Unknown", + "BLversion": "", + "PRIversion": "04.19", + "IMEI": "123456789012345", + "SVN": "9", + "MEID": "", + "ESN": "0", + "FSN": "FFFFFFFFFFFFF", + "activated": true, + "webAppVersion": "LM1200-HDATA_03.03.103.201", + "HIDenabled": false, + "TCAaccepted": true, + "LEDenabled": true, + "showAdvHelp": true, + "keyLockState": "Unlocked", + "devTemperature": 30, + "verMajor": 1000, + "verMinor": 0, + "environment": "Application", + "currTime": 1367257216, + "timeZoneOffset": -14400, + "deviceName": "LM1200", + "useMetricSystem": true, + "factoryResetStatus": "NotStarted", + "setupCompleted": true, + "languageSelected": false, + "systemAlertList": { "list": [{}], "count": 0 }, + "apiVersion": "2.0", + "companyName": "NETGEAR", + "configURL": "/Forms/config", + "profileURL": "/Forms/profile", + "pinChangeURL": "/Forms/pinChange", + "portCfgURL": "/Forms/portCfg", + "portFilterURL": "/Forms/portFilter", + "wifiACLURL": "/Forms/wifiACL", + "supportedLangList": [ + { + "id": "en", + "isCurrent": "true", + "isDefault": "true", + "label": "English", + "token1": "/romfs/lcd/en_us.tr", + "token2": "" + }, + { + "id": "de_DE", + "isCurrent": "false", + "isDefault": "false", + "label": "Deutsch (German)", + "token1": "/romfs/lcd/de_de.tr", + "token2": "" + }, + { + "id": "ar_AR", + "isCurrent": "false", + "isDefault": "false", + "label": "العربية (Arabic)", + "token1": "/romfs/lcd/ar_AR.tr", + "token2": "" + }, + { + "id": "es_ES", + "isCurrent": "false", + "isDefault": "false", + "label": "Español (Spanish)", + "token1": "/romfs/lcd/es_es.tr", + "token2": "" + }, + { + "id": "fr_FR", + "isCurrent": "false", + "isDefault": "false", + "label": "Français (French)", + "token1": "/romfs/lcd/fr_fr.tr", + "token2": "" + }, + { + "id": "it_IT", + "isCurrent": "false", + "isDefault": "false", + "label": "Italiano (Italian)", + "token1": "/romfs/lcd/it_it.tr", + "token2": "" + }, + { + "id": "pl_PL", + "isCurrent": "false", + "isDefault": "false", + "label": "Polski (Polish)", + "token1": "/romfs/lcd/pl_pl.tr", + "token2": "" + }, + { + "id": "fi_FI", + "isCurrent": "false", + "isDefault": "false", + "label": "Suomi (Finnish)", + "token1": "/romfs/lcd/fi_fi.tr", + "token2": "" + }, + { + "id": "sv_SE", + "isCurrent": "false", + "isDefault": "false", + "label": "Svenska (Swedish)", + "token1": "/romfs/lcd/sv_se.tr", + "token2": "" + }, + { + "id": "tu_TU", + "isCurrent": "false", + "isDefault": "false", + "label": "Türkçe (Turkish)", + "token1": "/romfs/lcd/tu_tu.tr", + "token2": "" + }, + {} + ] + }, + "power": { + "PMState": "Init", + "SmState": "Online", + "autoOff": { + "onUSBdisconnect": { "enable": false, "countdownTimer": 0, "end": "" }, + "onIdle": { "timer": { "onAC": 0, "onBattery": 0, "end": "" } } + }, + "standby": { + "onIdle": { + "timer": { "onAC": 0, "onBattery": 600, "onUSB": 0, "end": "" } + } + }, + "autoOn": { "enable": true, "end": "" }, + "buttonHoldTime": 3, + "deviceTempCritical": false, + "resetreason": 16, + "resetRequired": "NoResetRequired", + "lpm": false, + "end": "" + }, + "wwan": { + "netScanStatus": "NotStarted", + "inactivityCause": 307, + "currentNWserviceType": "LteService", + "registerRejectCode": 0, + "netSelEnabled": "Enabled", + "netRegMode": "Auto", + "IPv6": "1234:abcd::1234:abcd", + "roaming": false, + "IP": "10.0.0.5", + "registerNetworkDisplay": "T-Mobile", + "RAT": "Only4G", + "bandRegion": [ + { "index": 0, "name": "Auto", "current": false }, + { "index": 1, "name": "LTE Only", "current": true }, + { "index": 2, "name": "WCDMA Only", "current": false }, + {} + ], + "autoconnect": "HomeNetwork", + "profileList": [ + { + "index": 1, + "id": "T-Mobile 9", + "name": "T Mobile", + "apn": "fast.t-mobile.com", + "username": "", + "password": "", + "authtype": "None", + "ipaddr": "0.0.0.0", + "type": "IPV4V6", + "pdproamingtype": "IPV4" + }, + { + "index": 2, + "id": "Mint", + "name": "Mint", + "apn": "wholesale", + "username": "", + "password": "", + "authtype": "None", + "ipaddr": "0.0.0.0", + "type": "IPV4V6", + "pdproamingtype": "IPV4" + }, + {} + ], + "profile": { + "default": "T-Mobile 9", + "defaultLTE": "T-Mobile 9", + "full": false, + "promptForApnSelection": false, + "end": "" + }, + "dataUsage": { + "total": { + "lteBillingTx": 0, + "lteBillingRx": 0, + "cdmaBillingTx": 0, + "cdmaBillingRx": 0, + "gwBillingTx": 0, + "gwBillingRx": 0, + "lteLifeTx": 0, + "lteLifeRx": 0, + "cdmaLifeTx": 0, + "cdmaLifeRx": 0, + "gwLifeTx": 0, + "gwLifeRx": 0, + "end": "" + }, + "server": { "accountType": "", "subAccountType": "", "end": "" }, + "serverDataRemaining": 0, + "serverDataTransferred": 0, + "serverDataTransferredIntl": 0, + "serverDataValidState": "Invalid", + "serverDaysLeft": 0, + "serverErrorCode": "", + "serverLowBalance": false, + "serverMsisdn": "", + "serverRechargeUrl": "", + "dataWarnEnable": true, + "prepaidAccountState": "Hot", + "accountType": "Unknown", + "share": { + "enabled": false, + "dataTransferredOthers": 0, + "lastSync": "0", + "end": "" + }, + "generic": { + "dataLimitValid": false, + "usageHighWarning": 80, + "lastSucceeded": "0", + "billingDay": 1, + "nextBillingDate": "1369627200", + "lastSync": "0", + "billingCycleRemainder": 27, + "billingCycleLimit": 0, + "dataTransferred": 42484315, + "dataTransferredRoaming": 0, + "lastReset": "1366948800", + "end": "" + } + }, + "netManualNoCvg": false, + "connection": "Connected", + "connectionType": "IPv4AndIPv6", + "currentPSserviceType": "LTE", + "ca": { "end": "" }, + "connectionText": "4G", + "sessDuration": 4282, + "sessStartTime": 1367252934, + "dataTransferred": { "totalb": "345036", "rxb": "184700", "txb": "160336" }, + "signalStrength": { + "rssi": 0, + "rscp": 0, + "ecio": 0, + "rsrp": -113, + "rsrq": -20, + "bars": 2, + "sinr": 0, + "end": "" + } + }, + "wwanadv": { + "curBand": "LTE B4", + "radioQuality": 52, + "country": "USA", + "RAC": 0, + "LAC": 12345, + "MCC": "123", + "MNC": "456", + "MNCFmt": 3, + "cellId": 12345678, + "chanId": 2300, + "primScode": -1, + "plmnSrvErrBitMask": 0, + "chanIdUl": 20300, + "txLevel": 4, + "rxLevel": -113, + "end": "" + }, + "ethernet": { + "offload": { "ipv4Addr": "0.0.0.0", "ipv6Addr": "", "end": "" } + }, + "wifi": { + "enabled": true, + "maxClientSupported": 0, + "maxClientLimit": 0, + "maxClientCnt": 0, + "channel": 0, + "hiddenSSID": true, + "passPhrase": "", + "RTSthreshold": 0, + "fragThreshold": 0, + "SSID": "", + "clientCount": 0, + "country": "", + "wps": { "supported": "Disabled", "end": "" }, + "guest": { + "maxClientCnt": 0, + "enabled": false, + "SSID": "", + "passPhrase": "", + "generatePassphrase": false, + "hiddenSSID": true, + "chan": 0, + "DHCP": { "range": { "end": "" } } + }, + "offload": { "end": "" }, + "end": "" + }, + "router": { + "gatewayIP": "192.168.5.1", + "DMZaddress": "192.168.5.4", + "DMZenabled": false, + "forceSetup": false, + "DHCP": { + "serverEnabled": true, + "DNS1": "1.1.1.1", + "DNS2": "1.1.2.2", + "DNSmode": "Auto", + "USBpcIP": "0.0.0.0", + "leaseTime": 43200, + "range": { "high": "192.168.5.99", "low": "192.168.5.20", "end": "" } + }, + "usbMode": "None", + "usbNetworkTethering": true, + "portFwdEnabled": false, + "portFwdList": [{}], + "portFilteringEnabled": false, + "portFilteringMode": "None", + "portFilterWhiteList": [{}], + "portFilterBlackList": [{}], + "hostName": "routerlogin", + "domainName": "net", + "ipPassThroughEnabled": false, + "ipPassThroughSupported": true, + "Ipv6Supported": true, + "UPNPsupported": false, + "UPNPenabled": false, + "clientList": { "list": [{}], "count": 0 }, + "end": "" + }, + "fota": { + "fwupdater": { + "available": false, + "chkallow": true, + "chkstatus": "Initial", + "dloadProg": 0, + "error": false, + "lastChkDate": 1367200419, + "state": "NoNewFw", + "isPostponable": false, + "statusCode": 200, + "chkTimeLeft": 0, + "dloadSize": 0, + "end": "" + } + }, + "failover": { + "mode": "Auto", + "backhaul": "LTE", + "supported": true, + "monitorPeriod": 10, + "wanConnected": false, + "keepaliveEnable": false, + "keepaliveSleep": 15, + "ipv4Targets": [{ "id": "0", "string": "8.8.8.8" }, {}], + "ipv6Targets": [{}], + "end": "" + }, + "eventlog": { "level": 0, "end": 0 }, + "ui": { "serverDaysLeftHide": false, "promptActivation": true, "end": 0 } +} diff --git a/tests/components/netgear_lte/snapshots/test_binary_sensor.ambr b/tests/components/netgear_lte/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..6f3950aaabe800 --- /dev/null +++ b/tests/components/netgear_lte/snapshots/test_binary_sensor.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.netgear_lm1200_mobile_connected] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Netgear LM1200 Mobile connected', + }), + 'context': , + 'entity_id': 'binary_sensor.netgear_lm1200_mobile_connected', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.netgear_lm1200_roaming] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Roaming', + }), + 'context': , + 'entity_id': 'binary_sensor.netgear_lm1200_roaming', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.netgear_lm1200_wire_connected] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Netgear LM1200 Wire connected', + }), + 'context': , + 'entity_id': 'binary_sensor.netgear_lm1200_wire_connected', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/netgear_lte/snapshots/test_init.ambr b/tests/components/netgear_lte/snapshots/test_init.ambr new file mode 100644 index 00000000000000..2eb2fff89efa34 --- /dev/null +++ b/tests/components/netgear_lte/snapshots/test_init.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.5.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'netgear_lte', + 'FFFFFFFFFFFFF', + ), + }), + 'is_new': False, + 'manufacturer': 'Netgear', + 'model': 'LM1200', + 'name': 'Netgear LM1200', + 'name_by_user': None, + 'serial_number': 'FFFFFFFFFFFFF', + 'suggested_area': None, + 'sw_version': 'EC25AFFDR07A09M4G', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/netgear_lte/snapshots/test_sensor.ambr b/tests/components/netgear_lte/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..8d16ff29dfa39a --- /dev/null +++ b/tests/components/netgear_lte/snapshots/test_sensor.ambr @@ -0,0 +1,175 @@ +# serializer version: 1 +# name: test_sensors[sensor.netgear_lm1200_cell_id] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Cell ID', + 'icon': 'mdi:radio-tower', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_cell_id', + 'last_changed': , + 'last_updated': , + 'state': '12345678', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_connection_text] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Connection text', + 'icon': 'mdi:radio-tower', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_connection_text', + 'last_changed': , + 'last_updated': , + 'state': '4G', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_connection_type] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Connection type', + 'icon': 'mdi:ip', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_connection_type', + 'last_changed': , + 'last_updated': , + 'state': 'IPv4AndIPv6', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_current_band] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Current band', + 'icon': 'mdi:radio-tower', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_current_band', + 'last_changed': , + 'last_updated': , + 'state': 'LTE B4', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_radio_quality] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Radio quality', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_radio_quality', + 'last_changed': , + 'last_updated': , + 'state': '52', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_register_network_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Register network display', + 'icon': 'mdi:web', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_register_network_display', + 'last_changed': , + 'last_updated': , + 'state': 'T-Mobile', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_rx_level] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Netgear LM1200 Rx level', + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_rx_level', + 'last_changed': , + 'last_updated': , + 'state': '-113', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_service_type] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Service type', + 'icon': 'mdi:radio-tower', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_service_type', + 'last_changed': , + 'last_updated': , + 'state': 'LTE', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_sms] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 SMS', + 'icon': 'mdi:message-processing', + 'unit_of_measurement': 'unread', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_sms', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_sms_total] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 SMS total', + 'icon': 'mdi:message-processing', + 'unit_of_measurement': 'messages', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_sms_total', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_tx_level] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Netgear LM1200 Tx level', + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_tx_level', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_upstream] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Upstream', + 'icon': 'mdi:ip-network', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_upstream', + 'last_changed': , + 'last_updated': , + 'state': 'LTE', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_usage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Netgear LM1200 Usage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_usage', + 'last_changed': , + 'last_updated': , + 'state': '40.5162000656128', + }) +# --- diff --git a/tests/components/netgear_lte/test_binary_sensor.py b/tests/components/netgear_lte/test_binary_sensor.py new file mode 100644 index 00000000000000..660b7dd4fdf883 --- /dev/null +++ b/tests/components/netgear_lte/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""The tests for Netgear LTE binary sensor platform.""" +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + setup_integration: None, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for successfully setting up the Netgear LTE binary sensor platform.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + assert entity_entries + for entity_entry in entity_entries: + if entity_entry.domain != BINARY_SENSOR_DOMAIN: + continue + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=entity_entry.entity_id + ) diff --git a/tests/components/netgear_lte/test_config_flow.py b/tests/components/netgear_lte/test_config_flow.py new file mode 100644 index 00000000000000..97a624a14e7c08 --- /dev/null +++ b/tests/components/netgear_lte/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test Netgear LTE config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant + +from .conftest import CONF_DATA + + +def _patch_setup(): + return patch( + "homeassistant.components.netgear_lte.async_setup_entry", return_value=True + ) + + +async def test_flow_user_form(hass: HomeAssistant, connection: None) -> 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["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with _patch_setup(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Netgear LM1200" + assert result["data"] == CONF_DATA + assert result["context"]["unique_id"] == "FFFFFFFFFFFFF" + + +@pytest.mark.parametrize("source", (SOURCE_USER, SOURCE_IMPORT)) +async def test_flow_already_configured( + hass: HomeAssistant, setup_integration: None, source: str +) -> None: + """Test config flow aborts when already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: source}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_cannot_connect( + hass: HomeAssistant, cannot_connect: None +) -> None: + """Test connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + +async def test_flow_user_unknown_error(hass: HomeAssistant, unknown: None) -> None: + """Test unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "unknown" + + +async def test_flow_import(hass: HomeAssistant, connection: None) -> None: + """Test import step.""" + with _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Netgear LM1200" + assert result["data"] == CONF_DATA + + +async def test_flow_import_failure(hass: HomeAssistant, cannot_connect: None) -> None: + """Test import step failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/netgear_lte/test_init.py b/tests/components/netgear_lte/test_init.py new file mode 100644 index 00000000000000..9d9b43f5a16121 --- /dev/null +++ b/tests/components/netgear_lte/test_init.py @@ -0,0 +1,44 @@ +"""Test Netgear LTE integration.""" +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import CONF_DATA + + +async def test_setup_unload(hass: HomeAssistant, setup_integration: None) -> None: + """Test setup and unload.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.data == CONF_DATA + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready( + hass: HomeAssistant, setup_cannot_connect: None +) -> None: + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: None, + snapshot: SnapshotAssertion, +) -> None: + """Test device info.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.async_block_till_done() + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.unique_id)}) + assert device == snapshot diff --git a/tests/components/netgear_lte/test_notify.py b/tests/components/netgear_lte/test_notify.py new file mode 100644 index 00000000000000..12d906138c3e21 --- /dev/null +++ b/tests/components/netgear_lte/test_notify.py @@ -0,0 +1,29 @@ +"""The tests for the Netgear LTE notify platform.""" +from unittest.mock import patch + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TARGET, + DOMAIN as NOTIFY_DOMAIN, +) +from homeassistant.core import HomeAssistant + +ICON_PATH = "/some/path" +MESSAGE = "one, two, testing, testing" + + +async def test_notify(hass: HomeAssistant, setup_integration: None) -> None: + """Test sending a message.""" + assert hass.services.has_service(NOTIFY_DOMAIN, "netgear_lm1200") + + with patch("homeassistant.components.netgear_lte.eternalegypt.Modem.sms") as mock: + await hass.services.async_call( + NOTIFY_DOMAIN, + "netgear_lm1200", + { + ATTR_MESSAGE: MESSAGE, + ATTR_TARGET: "5555555556", + }, + blocking=True, + ) + assert len(mock.mock_calls) == 1 diff --git a/tests/components/netgear_lte/test_sensor.py b/tests/components/netgear_lte/test_sensor.py new file mode 100644 index 00000000000000..37f6538fe6af11 --- /dev/null +++ b/tests/components/netgear_lte/test_sensor.py @@ -0,0 +1,27 @@ +"""The tests for Netgear LTE sensor platform.""" +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.netgear_lte.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 + + +async def test_sensors( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + setup_integration: None, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for successfully setting up the Netgear LTE sensor platform.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + assert entity_entries + for entity_entry in entity_entries: + if entity_entry.domain != SENSOR_DOMAIN: + continue + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=entity_entry.entity_id + ) diff --git a/tests/components/netgear_lte/test_services.py b/tests/components/netgear_lte/test_services.py new file mode 100644 index 00000000000000..5c5c33be980a69 --- /dev/null +++ b/tests/components/netgear_lte/test_services.py @@ -0,0 +1,55 @@ +"""Services tests for the Netgear LTE integration.""" +from unittest.mock import patch + +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from .conftest import HOST + + +async def test_set_option(hass: HomeAssistant, setup_integration: None) -> None: + """Test service call set option.""" + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.set_failover_mode" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "set_option", + {CONF_HOST: HOST, "failover": "auto", "autoconnect": "home"}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 + + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.connect_lte" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "connect_lte", + {CONF_HOST: HOST}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 + + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.disconnect_lte" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "disconnect_lte", + {CONF_HOST: HOST}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 + + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.delete_sms" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "delete_sms", + {CONF_HOST: HOST, "sms_id": 1}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 diff --git a/tests/components/nextbus/test_config_flow.py b/tests/components/nextbus/test_config_flow.py index 9f427757183595..0b67f817eb2cff 100644 --- a/tests/components/nextbus/test_config_flow.py +++ b/tests/components/nextbus/test_config_flow.py @@ -5,13 +5,8 @@ import pytest from homeassistant import config_entries, setup -from homeassistant.components.nextbus.const import ( - CONF_AGENCY, - CONF_ROUTE, - CONF_STOP, - DOMAIN, -) -from homeassistant.const import CONF_NAME +from homeassistant.components.nextbus.const import CONF_AGENCY, CONF_ROUTE, DOMAIN +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index a4d04997e15997..92da27783bc259 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -8,15 +8,10 @@ import pytest from homeassistant.components import sensor -from homeassistant.components.nextbus.const import ( - CONF_AGENCY, - CONF_ROUTE, - CONF_STOP, - DOMAIN, -) +from homeassistant.components.nextbus.const import CONF_AGENCY, CONF_ROUTE, DOMAIN from homeassistant.components.nextbus.coordinator import NextBusDataUpdateCoordinator from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.update_coordinator import UpdateFailed diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 071d14f183b907..5040c6e052e3fc 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'nextdns', 'entry_id': 'd9aa37407ddac7b964a99e86312288d6', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index b5d718b61aa790..a27898629ad0f8 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -6,13 +6,9 @@ import pytest from homeassistant import data_entry_flow -from homeassistant.components.nextdns.const import ( - CONF_PROFILE_ID, - CONF_PROFILE_NAME, - DOMAIN, -) +from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME from homeassistant.core import HomeAssistant from . import PROFILES, init_integration diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 14a1a0e1768515..07a67cb142950a 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -18,6 +18,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": DOMAIN, "title": REDACTED, "data": {"username": REDACTED, "password": REDACTED}, diff --git a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py index 673ac1a72d46fd..bf56cb8a98568c 100644 --- a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py +++ b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py @@ -3,6 +3,7 @@ from unittest.mock import ANY, MagicMock, call, patch from aio_geojson_nsw_rfs_incidents import NswRuralFireServiceIncidentsFeed +from freezegun.api import FrozenDateTimeFactory from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE @@ -90,7 +91,7 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -114,11 +115,10 @@ async def test_setup(hass: HomeAssistant) -> None: mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (-31.2, 150.2)) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (-31.3, 150.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update: + freezer.move_to(utcnow) + + with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update: mock_feed_update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], diff --git a/tests/components/number/test_const.py b/tests/components/number/test_const.py new file mode 100644 index 00000000000000..e4b47e17e6edeb --- /dev/null +++ b/tests/components/number/test_const.py @@ -0,0 +1,16 @@ +"""Test the number const module.""" + +import pytest + +from homeassistant.components.number import const + +from tests.common import import_and_test_deprecated_constant_enum + + +@pytest.mark.parametrize(("enum"), list(const.NumberMode)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: const.NumberMode, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, const, enum, "MODE_", "2025.1") diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 601a34d4271315..4de47b9b844fbc 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -131,6 +131,31 @@ def native_value(self): return None +class MockNumberEntityAttrWithDescription(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class sets an entity description and overrides + all the values with _attr members to ensure the _attr + members take precedence over the entity description. + """ + + def __init__(self): + """Initialize the clas instance.""" + self.entity_description = NumberEntityDescription( + "test", + native_max_value=10.0, + native_min_value=-10.0, + native_step=2.0, + native_unit_of_measurement="native_rabbits", + ) + + _attr_native_max_value = 1000.0 + _attr_native_min_value = -1000.0 + _attr_native_step = 100.0 + _attr_native_unit_of_measurement = "native_dogs" + _attr_native_value = 500.0 + + class MockDefaultNumberEntityDeprecated(NumberEntity): """Mock NumberEntity device to use in tests. @@ -277,6 +302,21 @@ async def test_attributes(hass: HomeAssistant) -> None: ATTR_STEP: 2.0, } + number_5 = MockNumberEntityAttrWithDescription() + number_5.hass = hass + assert number_5.max_value == 1000.0 + assert number_5.min_value == -1000.0 + assert number_5.step == 100.0 + assert number_5.native_step == 100.0 + assert number_5.unit_of_measurement == "native_dogs" + assert number_5.value == 500.0 + assert number_5.capability_attributes == { + ATTR_MAX: 1000.0, + ATTR_MIN: -1000.0, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 100.0, + } + async def test_sync_set_value(hass: HomeAssistant) -> None: """Test if async set_value calls sync set_value.""" diff --git a/tests/components/number/test_significant_change.py b/tests/components/number/test_significant_change.py new file mode 100644 index 00000000000000..1a6491f3de9f9f --- /dev/null +++ b/tests/components/number/test_significant_change.py @@ -0,0 +1,94 @@ +"""Test the Number significant change platform.""" +import pytest + +from homeassistant.components.number import NumberDeviceClass +from homeassistant.components.number.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) + +AQI_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.AQI} +BATTERY_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.BATTERY} +CO_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.CO} +CO2_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.CO2} +HUMIDITY_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.HUMIDITY} +MOISTURE_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.MOISTURE} +PM1_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.PM1} +PM10_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.PM10} +PM25_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.PM25} +POWER_FACTOR_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.POWER_FACTOR, +} +POWER_FACTOR_ATTRS_PERCENTAGE = { + ATTR_DEVICE_CLASS: NumberDeviceClass.POWER_FACTOR, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, +} +TEMP_CELSIUS_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, +} +TEMP_FREEDOM_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, +} +VOLATILE_ORGANIC_COMPOUNDS_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS +} + + +@pytest.mark.parametrize( + ("old_state", "new_state", "attrs", "result"), + [ + ("0", "0.9", {}, None), + ("0", "1", AQI_ATTRS, True), + ("1", "0", AQI_ATTRS, True), + ("0.1", "0.5", AQI_ATTRS, False), + ("0.5", "0.1", AQI_ATTRS, False), + ("99", "100", AQI_ATTRS, False), + ("100", "99", AQI_ATTRS, False), + ("101", "99", AQI_ATTRS, False), + ("99", "101", AQI_ATTRS, True), + ("100", "100", BATTERY_ATTRS, False), + ("100", "99", BATTERY_ATTRS, True), + ("0", "1", CO_ATTRS, True), + ("0.1", "0.5", CO_ATTRS, False), + ("0", "1", CO2_ATTRS, True), + ("0.1", "0.5", CO2_ATTRS, False), + ("100", "100", HUMIDITY_ATTRS, False), + ("100", "99", HUMIDITY_ATTRS, True), + ("100", "100", MOISTURE_ATTRS, False), + ("100", "99", MOISTURE_ATTRS, True), + ("0", "1", PM1_ATTRS, True), + ("0.1", "0.5", PM1_ATTRS, False), + ("0", "1", PM10_ATTRS, True), + ("0.1", "0.5", PM10_ATTRS, False), + ("0", "1", PM25_ATTRS, True), + ("0.1", "0.5", PM25_ATTRS, False), + ("0.1", "0.2", POWER_FACTOR_ATTRS, True), + ("0.1", "0.19", POWER_FACTOR_ATTRS, False), + ("1", "2", POWER_FACTOR_ATTRS_PERCENTAGE, True), + ("1", "1.9", POWER_FACTOR_ATTRS_PERCENTAGE, False), + ("12", "12", TEMP_CELSIUS_ATTRS, False), + ("12", "13", TEMP_CELSIUS_ATTRS, True), + ("12.1", "12.2", TEMP_CELSIUS_ATTRS, False), + ("70", "71", TEMP_FREEDOM_ATTRS, True), + ("70", "70.5", TEMP_FREEDOM_ATTRS, False), + ("fail", "70", TEMP_FREEDOM_ATTRS, True), + ("70", "fail", TEMP_FREEDOM_ATTRS, False), + ("0", "1", VOLATILE_ORGANIC_COMPOUNDS_ATTRS, True), + ("0.1", "0.5", VOLATILE_ORGANIC_COMPOUNDS_ATTRS, False), + ], +) +async def test_significant_change_temperature( + old_state, new_state, attrs, result +) -> None: + """Detect temperature significant changes.""" + assert ( + async_check_significant_change(None, old_state, attrs, new_state, attrs) + is result + ) diff --git a/tests/components/nws/snapshots/test_weather.ambr b/tests/components/nws/snapshots/test_weather.ambr index 0dddca954bee18..0db2311085ccd0 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 54069eec02cf3a..c7478be7c07b9f 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -13,7 +13,8 @@ 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/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index 2ba657c77d5210..3d3efd04da09dc 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -1,6 +1,7 @@ """The tests for Octoptint binary sensor module.""" from datetime import UTC, datetime -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -8,7 +9,7 @@ from . import init_integration -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the underlying sensors.""" printer = { "state": { @@ -22,11 +23,8 @@ async def test_sensors(hass: HomeAssistant) -> None: "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Printing", } - with patch( - "homeassistant.util.dt.utcnow", - return_value=datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC), - ): - await init_integration(hass, "sensor", printer=printer, job=job) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC)) + await init_integration(hass, "sensor", printer=printer, job=job) entity_registry = er.async_get(hass) @@ -80,7 +78,9 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry.unique_id == "Estimated Finish Time-uuid" -async def test_sensors_no_target_temp(hass: HomeAssistant) -> None: +async def test_sensors_no_target_temp( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the underlying sensors.""" printer = { "state": { @@ -89,10 +89,8 @@ async def test_sensors_no_target_temp(hass: HomeAssistant) -> None: }, "temperature": {"tool1": {"actual": 18.83136, "target": None}}, } - with patch( - "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) - ): - await init_integration(hass, "sensor", printer=printer) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "sensor", printer=printer) entity_registry = er.async_get(hass) @@ -111,7 +109,9 @@ async def test_sensors_no_target_temp(hass: HomeAssistant) -> None: assert entry.unique_id == "target tool1 temp-uuid" -async def test_sensors_paused(hass: HomeAssistant) -> None: +async def test_sensors_paused( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the underlying sensors.""" printer = { "state": { @@ -125,10 +125,8 @@ async def test_sensors_paused(hass: HomeAssistant) -> None: "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Paused", } - with patch( - "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) - ): - await init_integration(hass, "sensor", printer=printer, job=job) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "sensor", printer=printer, job=job) entity_registry = er.async_get(hass) @@ -147,17 +145,17 @@ async def test_sensors_paused(hass: HomeAssistant) -> None: assert entry.unique_id == "Estimated Finish Time-uuid" -async def test_sensors_printer_disconnected(hass: HomeAssistant) -> None: +async def test_sensors_printer_disconnected( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the underlying sensors.""" job = { "job": {}, "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Paused", } - with patch( - "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) - ): - await init_integration(hass, "sensor", printer=None, job=job) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "sensor", printer=None, job=job) entity_registry = er.async_get(hass) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index c888381230cc89..47568a7d760b23 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/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr index e10c8791ba92c5..c4f692a4e61102 100644 --- a/tests/components/onvif/snapshots/test_diagnostics.ambr +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -13,6 +13,7 @@ 'disabled_by': None, 'domain': 'onvif', 'entry_id': '1', + 'minor_version': 1, 'options': dict({ 'enable_webhooks': True, 'extra_arguments': '-pred 1', diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 9f00290600e8f8..a83c660e509ca1 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.resources.models.AsyncModels.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 471be8035b6df3..dd218e88c126f1 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -1,7 +1,8 @@ """Test the OpenAI Conversation config flow.""" from unittest.mock import patch -from openai.error import APIConnectionError, AuthenticationError, InvalidRequestError +from httpx import Response +from openai import APIConnectionError, AuthenticationError, BadRequestError import pytest from homeassistant import config_entries @@ -32,7 +33,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.resources.models.AsyncModels.list", ), patch( "homeassistant.components.openai_conversation.async_setup_entry", return_value=True, @@ -76,9 +77,19 @@ async def test_options( @pytest.mark.parametrize( ("side_effect", "error"), [ - (APIConnectionError(""), "cannot_connect"), - (AuthenticationError, "invalid_auth"), - (InvalidRequestError, "unknown"), + (APIConnectionError(request=None), "cannot_connect"), + ( + AuthenticationError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "invalid_auth", + ), + ( + BadRequestError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "unknown", + ), ], ) async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> None: @@ -88,7 +99,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.resources.models.AsyncModels.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 1b145d9d545869..d3a06cabeb39a4 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,7 +1,18 @@ """Tests for the OpenAI integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from openai import error +from httpx import Response +from openai import ( + APIConnectionError, + AuthenticationError, + BadRequestError, + RateLimitError, +) +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_message import ChatCompletionMessage +from openai.types.completion_usage import CompletionUsage +from openai.types.image import Image +from openai.types.images_response import ImagesResponse import pytest from syrupy.assertion import SnapshotAssertion @@ -9,6 +20,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar, device_registry as dr, intent +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -94,17 +106,30 @@ async def test_default_prompt( suggested_area="Test Area 2", ) with patch( - "openai.ChatCompletion.acreate", - return_value={ - "choices": [ - { - "message": { - "role": "assistant", - "content": "Hello, how can I help you?", - } - } - ] - }, + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), ) as mock_create: result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id @@ -119,7 +144,11 @@ async def test_error_handling( ) -> None: """Test that the default prompt works.""" with patch( - "openai.ChatCompletion.acreate", side_effect=error.ServiceUnavailableError + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=RateLimitError( + response=Response(status_code=None, request=""), body=None, message=None + ), ): result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id @@ -140,8 +169,11 @@ async def test_template_error( }, ) with patch( - "openai.Engine.list", - ), patch("openai.ChatCompletion.acreate"): + "openai.resources.models.AsyncModels.list", + ), patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( @@ -169,15 +201,67 @@ async def test_conversation_agent( [ ( {"prompt": "Picture of a dog"}, - {"prompt": "Picture of a dog", "size": "512x512"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, + ), + ( + { + "prompt": "Picture of a dog", + "size": "1024x1792", + "quality": "hd", + "style": "vivid", + }, + { + "prompt": "Picture of a dog", + "size": "1024x1792", + "quality": "hd", + "style": "vivid", + }, + ), + ( + { + "prompt": "Picture of a dog", + "size": "1792x1024", + "quality": "standard", + "style": "natural", + }, + { + "prompt": "Picture of a dog", + "size": "1792x1024", + "quality": "standard", + "style": "natural", + }, ), ( {"prompt": "Picture of a dog", "size": "256"}, - {"prompt": "Picture of a dog", "size": "256x256"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, + ), + ( + {"prompt": "Picture of a dog", "size": "512"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, ), ( {"prompt": "Picture of a dog", "size": "1024"}, - {"prompt": "Picture of a dog", "size": "1024x1024"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, ), ], ) @@ -190,11 +274,22 @@ async def test_generate_image_service( ) -> None: """Test generate image service.""" service_data["config_entry"] = mock_config_entry.entry_id - expected_args["api_key"] = mock_config_entry.data["api_key"] + expected_args["model"] = "dall-e-3" + expected_args["response_format"] = "url" expected_args["n"] = 1 with patch( - "openai.Image.acreate", return_value={"data": [{"url": "A"}]} + "openai.resources.images.AsyncImages.generate", + return_value=ImagesResponse( + created=1700000000, + data=[ + Image( + b64_json=None, + revised_prompt="A clear and detailed picture of an ordinary canine", + url="A", + ) + ], + ), ) as mock_create: response = await hass.services.async_call( "openai_conversation", @@ -204,7 +299,10 @@ async def test_generate_image_service( return_response=True, ) - assert response == {"url": "A"} + assert response == { + "url": "A", + "revised_prompt": "A clear and detailed picture of an ordinary canine", + } assert len(mock_create.mock_calls) == 1 assert mock_create.mock_calls[0][2] == expected_args @@ -216,7 +314,10 @@ async def test_generate_image_service_error( ) -> None: """Test generate image service handles errors.""" with patch( - "openai.Image.acreate", side_effect=error.ServiceUnavailableError("Reason") + "openai.resources.images.AsyncImages.generate", + side_effect=RateLimitError( + response=Response(status_code=None, request=""), body=None, message="Reason" + ), ), pytest.raises(HomeAssistantError, match="Error generating image: Reason"): await hass.services.async_call( "openai_conversation", @@ -228,3 +329,34 @@ async def test_generate_image_service_error( blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (APIConnectionError(request=None), "Connection error"), + ( + AuthenticationError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "Invalid API key", + ), + ( + BadRequestError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "openai_conversation integration not ready yet: None", + ), + ], +) +async def test_init_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, caplog, side_effect, error +) -> None: + """Test initialization errors.""" + with patch( + "openai.resources.models.AsyncModels.list", + side_effect=side_effect, + ): + assert await async_setup_component(hass, "openai_conversation", {}) + await hass.async_block_till_done() + assert error in caplog.text diff --git a/tests/components/opengarage/conftest.py b/tests/components/opengarage/conftest.py new file mode 100644 index 00000000000000..189c3a877ffffc --- /dev/null +++ b/tests/components/opengarage/conftest.py @@ -0,0 +1,59 @@ +"""Fixtures for the OpenGarage integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.opengarage.const import CONF_DEVICE_KEY, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Test device", + domain=DOMAIN, + data={ + CONF_HOST: "http://1.1.1.1", + CONF_PORT: "80", + CONF_DEVICE_KEY: "abc123", + CONF_VERIFY_SSL: False, + }, + unique_id="12345", + ) + + +@pytest.fixture +def mock_opengarage() -> Generator[MagicMock, None, None]: + """Return a mocked OpenGarage client.""" + with patch( + "homeassistant.components.opengarage.opengarage.OpenGarage", + autospec=True, + ) as client_mock: + client = client_mock.return_value + client.device_url = "http://1.1.1.1:80" + client.update_state.return_value = { + "name": "abcdef", + "mac": "aa:bb:cc:dd:ee:ff", + "fwv": "1.2.0", + } + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_opengarage: MagicMock +) -> MockConfigEntry: + """Set up the OpenGarage 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 diff --git a/tests/components/opengarage/test_button.py b/tests/components/opengarage/test_button.py new file mode 100644 index 00000000000000..b4557a116e8160 --- /dev/null +++ b/tests/components/opengarage/test_button.py @@ -0,0 +1,33 @@ +"""Test the OpenGarage Browser buttons.""" +from unittest.mock import MagicMock + +import homeassistant.components.button as button +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 + + +async def test_buttons( + hass: HomeAssistant, + mock_opengarage: MagicMock, + init_integration: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test standard OpenGarage buttons.""" + entry = entity_registry.async_get("button.abcdef_restart") + assert entry + assert entry.unique_id == "12345_restart" + await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.abcdef_restart"}, + blocking=True, + ) + assert len(mock_opengarage.reboot.mock_calls) == 1 + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 0f2c15a5e4a011..ef1ac166f1e5ea 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/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index fa7c7898037367..e7efc4596309cd 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -19,6 +19,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "openuv", "title": REDACTED, "data": { diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index 2bd62936fe5e22..87f76817044ca9 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -5,7 +5,6 @@ from homeassistant import data_entry_flow from homeassistant.components.openweathermap.const import ( - CONF_LANGUAGE, DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, DOMAIN, @@ -13,6 +12,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index 49de6db6e136a3..b48ccad2fe2aa4 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -24,6 +23,7 @@ inject_bluetooth_service_info, inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -63,9 +63,8 @@ async def test_sensors( # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -114,9 +113,8 @@ async def test_sensors_io_series_4( # Fast-forward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/osoenergy/__init__.py b/tests/components/osoenergy/__init__.py new file mode 100644 index 00000000000000..76d134ef0f568b --- /dev/null +++ b/tests/components/osoenergy/__init__.py @@ -0,0 +1 @@ +"""Tests for the OSO Hotwater integration.""" diff --git a/tests/components/osoenergy/test_config_flow.py b/tests/components/osoenergy/test_config_flow.py new file mode 100644 index 00000000000000..5c7e0b3442c02f --- /dev/null +++ b/tests/components/osoenergy/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the OSO Energy config flow.""" +from unittest.mock import patch + +from apyosoenergyapi.helper import osoenergy_exceptions + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.osoenergy.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +SUBSCRIPTION_KEY = "valid subscription key" +SCAN_INTERVAL = 120 +TEST_USER_EMAIL = "test_user_email@domain.com" +UPDATED_SCAN_INTERVAL = 60 + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ), patch( + "homeassistant.components.osoenergy.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_USER_EMAIL + assert result2["data"] == { + CONF_API_KEY: SUBSCRIPTION_KEY, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test the reauth flow.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config.unique_id, + "entry_id": mock_config.entry_id, + }, + data=mock_config.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get(CONF_API_KEY) == SUBSCRIPTION_KEY + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: + """Check flow abort when an entry already exist.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_invalid_subscription_key(hass: HomeAssistant) -> None: + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_exception_on_subscription_key_check( + hass: HomeAssistant, +) -> None: + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + side_effect=osoenergy_exceptions.OSOEnergyReauthRequired(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/otbr/test_util.py b/tests/components/otbr/test_util.py index 171a607d200c68..941c80a52da365 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 cba046a2a9d979..8288e7e9f70af3 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 00000000000000..6f90cb7ea1b2fe --- /dev/null +++ b/tests/components/ourgroceries/__init__.py @@ -0,0 +1,6 @@ +"""Tests for the OurGroceries integration.""" + + +def items_to_shopping_list(items: list, version_id: str = "1") -> dict[dict[list]]: + """Convert a list of items into a shopping list.""" + return {"list": {"versionId": version_id, "items": items}} diff --git a/tests/components/ourgroceries/conftest.py b/tests/components/ourgroceries/conftest.py new file mode 100644 index 00000000000000..c5fdec3ecb7055 --- /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", "versionId": "1"}] + } + 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 00000000000000..f9d274125c1610 --- /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 00000000000000..ef96c5e811c4d5 --- /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 00000000000000..649e86f2b056e7 --- /dev/null +++ b/tests/components/ourgroceries/test_todo.py @@ -0,0 +1,281 @@ +"""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 + + +def _mock_version_id(og: AsyncMock, version: int) -> None: + og.get_my_lists.return_value["shoppingLists"][0]["versionId"] = str(version) + + +@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 + _mock_version_id(ourgroceries, 2) + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Soda"}], + version_id="2", + ) + + 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 + _mock_version_id(ourgroceries, 2) + 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 + _mock_version_id(ourgroceries, 2) + 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", "category"), + [ + ( + [{"id": "12345", "name": "Soda", "categoryId": "test_category"}], + "test_category", + ), + ([{"id": "12345", "name": "Uncategorized"}], None), + ], +) +async def test_update_todo_item_summary( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, + category: str | None, +) -> 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 + _mock_version_id(ourgroceries, 2) + 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", 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 + _mock_version_id(ourgroceries, 2) + 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" + + +async def test_version_id_optimization( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test that list items aren't being retrieved if version id stays the same.""" + state = hass.states.get("todo.test_list") + assert state.state == "0" + assert ourgroceries.get_list_items.call_count == 1 + 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 == "0" + assert ourgroceries.get_list_items.call_count == 1 + + +@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" + + _mock_version_id(ourgroceries, 2) + 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/conftest.py b/tests/components/overkiz/conftest.py index 990b88d84ed327..da6d3a60839915 100644 --- a/tests/components/overkiz/conftest.py +++ b/tests/components/overkiz/conftest.py @@ -12,8 +12,8 @@ from tests.components.overkiz.test_config_flow import ( TEST_EMAIL, TEST_GATEWAY_ID, - TEST_HUB, TEST_PASSWORD, + TEST_SERVER, ) MOCK_SETUP_RESPONSE = Mock(devices=[], gateways=[]) @@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry: title="Somfy TaHoma Switch", 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}, ) diff --git a/tests/components/overkiz/snapshots/test_diagnostics.ambr b/tests/components/overkiz/snapshots/test_diagnostics.ambr index 06a456f88af9e0..a4ba28ec935a5d 100644 --- a/tests/components/overkiz/snapshots/test_diagnostics.ambr +++ b/tests/components/overkiz/snapshots/test_diagnostics.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_device_diagnostics dict({ + 'api_type': 'cloud', 'device': dict({ 'controllable_name': 'rts:RollerShutterRTSComponent', 'device_url': 'rts://****-****-6867/16756006', @@ -969,6 +970,7 @@ # --- # name: test_diagnostics dict({ + 'api_type': 'cloud', 'execution_history': list([ ]), 'server': 'somfy_europe', diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index a9d950a3a6626a..146d54feb9c322 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_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_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,109 +202,554 @@ 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, "unsupported_hardware"), + (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_cozytouch_auth( +async def test_form_invalid_auth_local( hass: HomeAssistant, side_effect: Exception, error: str ) -> None: - """Test we handle invalid auth from CozyTouch.""" + """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): - result2 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], { + "host": TEST_HOST, "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": TEST_HUB_COZYTOUCH, + "verify_ssl": True, }, ) - 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} + +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( + ("side_effect", "error"), + [ + (BadCredentialsException, "unsupported_hardware"), + ], +) +async def test_form_invalid_cozytouch_auth( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """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): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.FlowResultType.FORM + assert result3["errors"] == {"base": error} + assert result3["step_id"] == "cloud" + + +async def test_cloud_abort_on_duplicate_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" -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}, + 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, ): - 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}, ) - assert result2["type"] == data_entry_flow.FlowResultType.ABORT - assert result2["reason"] == "already_configured" + 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.""" -async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: - """Test config flow allows Config Flow unique entries.""" 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": "test2@testdomain.com", "password": TEST_PASSWORD}, + 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, ): - 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"] == { + "api_type": "cloud", "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": TEST_HUB, + "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"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, + ) + + 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: """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( @@ -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,121 +835,114 @@ 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_zeroconf_flow_already_configured(hass: HomeAssistant) -> None: - """Test that zeroconf doesn't setup already configured gateways.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, - ) - config_entry.add_to_hass(hass) - +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, + data=FAKE_ZERO_CONF_INFO_LOCAL, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_reauth_success(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER - mock_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, ) - 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 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 result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" + assert result3["type"] == "form" + assert result3["step_id"] == "local" - with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", - return_value=MOCK_GATEWAY_RESPONSE, + 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), ): - result = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, - "hub": TEST_HUB2, - }, + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "verify_ssl": False}, ) - 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 + 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 -async def test_reauth_wrong_account(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - mock_entry = MockConfigEntry( +async def test_zeroconf_flow_already_configured(hass: HomeAssistant) -> None: + """Test that zeroconf doesn't setup already configured gateways.""" + config_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) - mock_entry.add_to_hass(hass) + config_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, + data=FAKE_ZERO_CONF_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, ) - 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" + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/overkiz/test_init.py b/tests/components/overkiz/test_init.py index 774f3c9a79a1e2..ddecee7c16700f 100644 --- a/tests/components/overkiz/test_init.py +++ b/tests/components/overkiz/test_init.py @@ -4,7 +4,7 @@ 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 47f43dd340196a..55d4ccc5e67c92 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 532450f0099454..9ce87d707ff6b8 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,130 @@ 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 + assert result["step_id"] == "user" - with pytest.raises(MultipleInvalid): + with patch( + "homeassistant.components.peco.async_setup_entry", + return_value=True, + ), pytest.raises(MultipleInvalid): await hass.config_entries.flow.async_configure( result["flow_id"], { - "county": "INVALID_COUNTY_THAT_SHOULD_NOT_EXIST", + "county": "INVALID_COUNTY_THAT_SHOULDNT_EXIST", }, ) await hass.async_block_till_done() - second_result = await hass.config_entries.flow.async_init( + +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 second_result["type"] == FlowResultType.FORM - assert second_result["errors"] is None + assert result["type"] == FlowResultType.FORM + 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"], + 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.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 second_result2["type"] == FlowResultType.CREATE_ENTRY - assert second_result2["title"] == "Philadelphia Outage Count" - assert second_result2["data"] == { - "county": "PHILADELPHIA", - } + 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.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.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.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 52a7ddd3b252b7..2919e508c97ed3 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 @@ 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 00000000000000..56e779eef4d77e --- /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 00000000000000..2dcf9bd5ad2693 --- /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 00000000000000..cb8a0c32f17c3d --- /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 00000000000000..ad61ead7bfca7d --- /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 71491ee3cafde7..1866f682b5538f 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,4 +1,5 @@ """The tests for the person component.""" +from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -29,7 +30,7 @@ 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.typing import ClientSessionGenerator, WebSocketGenerator async def test_minimal_setup(hass: HomeAssistant) -> None: @@ -847,3 +848,30 @@ async def test_entities_in_person(hass: HomeAssistant) -> None: "device_tracker.paulus_iphone", "device_tracker.paulus_ipad", ] + + +async def test_list_persons( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_admin_user: MockUser, +) -> 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", {}) + client = await hass_client_no_auth() + + resp = await client.get("/api/person/list") + + assert resp.status == HTTPStatus.BAD_REQUEST + result = await resp.json() + assert result == {"code": "not_local", "message": "Not local"} diff --git a/tests/components/philips_js/__init__.py b/tests/components/philips_js/__init__.py index f524a586fc82f8..60e8b238917b82 100644 --- a/tests/components/philips_js/__init__.py +++ b/tests/components/philips_js/__init__.py @@ -73,3 +73,129 @@ } 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 00000000000000..01233706d07b2c --- /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/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 603e278d592484..8229f4e8fa9ee7 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -160,6 +160,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) "data": MOCK_CONFIG_PAIRED, "version": 1, "options": {}, + "minor_version": 1, } await hass.async_block_till_done() diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index 69c77acc64aede..865494b5e9feb1 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -25,6 +25,7 @@ 'disabled_by': None, 'domain': 'pi_hole', 'entry_id': 'pi_hole_mock_entry', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py new file mode 100644 index 00000000000000..5bb84c7a1c1dbd --- /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 00000000000000..bde170bb26a1eb --- /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 00000000000000..61a7fe7ac350c8 --- /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 00000000000000..3656d11e98cc8c --- /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 00000000000000..4b92584c0fc729 --- /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_config_flow.py b/tests/components/picnic/test_config_flow.py index a649240bd21958..d90551b01df1a7 100644 --- a/tests/components/picnic/test_config_flow.py +++ b/tests/components/picnic/test_config_flow.py @@ -6,8 +6,8 @@ import requests from homeassistant import config_entries, data_entry_flow -from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.picnic.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index cae10320fb9be4..fb1fbe9f009fe2 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -9,11 +9,12 @@ from homeassistant import config_entries from homeassistant.components.picnic import const -from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, DOMAIN +from homeassistant.components.picnic.const import DOMAIN from homeassistant.components.picnic.sensor import SENSOR_TYPES from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONF_ACCESS_TOKEN, + CONF_COUNTRY_CODE, CURRENCY_EURO, STATE_UNAVAILABLE, STATE_UNKNOWN, diff --git a/tests/components/picnic/test_todo.py b/tests/components/picnic/test_todo.py new file mode 100644 index 00000000000000..cdd30967058e49 --- /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 00000000000000..24dd3314e3c2ae --- /dev/null +++ b/tests/components/ping/conftest.py @@ -0,0 +1,59 @@ +"""Test configuration for ping.""" +from unittest.mock import patch + +from icmplib import Host +import pytest + +from homeassistant.components.device_tracker.const import CONF_CONSIDER_HOME +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, + CONF_CONSIDER_HOME: 180, + }, + ) + + +@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 00000000000000..048924292c7348 --- /dev/null +++ b/tests/components/ping/const.py @@ -0,0 +1,14 @@ +"""Constants for tests.""" +from datetime import timedelta + +from icmplib import Host + +BINARY_SENSOR_IMPORT_DATA = { + "name": "test2", + "host": "127.0.0.1", + "count": 1, + "scan_interval": 50, + "consider_home": timedelta(seconds=240), +} + +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 201c020835e821..00000000000000 --- 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 00000000000000..2ce320d561b8ea --- /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 3389534483f6dd..b1066895e2b987 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")) -async def test_reload(hass: HomeAssistant, mock_ping: None) -> None: - """Verify we can reload trend sensors.""" + state = hass.states.get("binary_sensor.10_10_10_10") + assert state == snapshot - await setup.async_setup_component( + # 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_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) + + 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 00000000000000..8757a5b5e0d01d --- /dev/null +++ b/tests/components/ping/test_config_flow.py @@ -0,0 +1,126 @@ +"""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, + "consider_home": 180, + } + + +@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, "consider_home": 180}, + 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", + "consider_home": 180, + } + + +@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, + "consider_home": 240, + } + + # 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, + "consider_home": 180, + } diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py new file mode 100644 index 00000000000000..d91cb46da0cf64 --- /dev/null +++ b/tests/components/ping/test_device_tracker.py @@ -0,0 +1,127 @@ +"""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 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, async_fire_time_changed, patch_yaml_files + + +@pytest.mark.usefixtures("setup_integration") +async def test_setup_and_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> 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() + + state = hass.states.get("device_tracker.10_10_10_10") + assert state.state == "home" + + with patch( + "homeassistant.components.ping.helpers.async_ping", + return_value=Host(address="10.10.10.10", packets_sent=10, rtts=[]), + ): + # we need to travel two times into the future to run the update twice + freezer.tick(timedelta(minutes=1, seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + freezer.tick(timedelta(minutes=4, seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("device_tracker.10_10_10_10")) + assert state.state == "not_home" + + freezer.tick(timedelta(minutes=1, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (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_button.py b/tests/components/plex/test_button.py index e8e734143b30fe..a37a3ea2df2e66 100644 --- a/tests/components/plex/test_button.py +++ b/tests/components/plex/test_button.py @@ -30,7 +30,7 @@ async def test_scan_clients_button_schedule( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.scan_clients_plex_server_1", + ATTR_ENTITY_ID: "button.plex_server_1_scan_clients", }, True, ) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 235596715f4aac..47d70727890210 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -851,7 +851,7 @@ class MockRequest: ), 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/plex/test_init.py b/tests/components/plex/test_init.py index 6e1043b5c522d6..e12759b8a1fe54 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -120,7 +120,7 @@ async def test_setup_with_photo_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "0" @@ -142,7 +142,7 @@ async def test_setup_with_live_tv_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "1" @@ -164,7 +164,7 @@ async def test_setup_with_transient_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "1" @@ -186,7 +186,7 @@ async def test_setup_with_unknown_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "1" diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 5b9729792f40e5..93014dfedd145d 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -110,7 +110,7 @@ async def test_library_sensor_values( mock_plex_server = await setup_plex_server() await wait_for_debouncer(hass) - activity_sensor = hass.states.get("sensor.plex_plex_server_1") + activity_sensor = hass.states.get("sensor.plex_server_1") assert activity_sensor.state == "1" # Ensure sensor is created as disabled diff --git a/tests/components/plex/test_server.py b/tests/components/plex/test_server.py index 1041caa298f617..511025988edddf 100644 --- a/tests/components/plex/test_server.py +++ b/tests/components/plex/test_server.py @@ -87,7 +87,7 @@ async def test_new_ignored_users_available( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) @@ -101,7 +101,7 @@ async def test_network_error_during_refresh( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) with patch("plexapi.server.PlexServer.clients", side_effect=RequestException): @@ -126,7 +126,7 @@ async def test_gdm_client_failure( active_sessions = mock_plex_server._plex_server.sessions() await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) with patch("plexapi.server.PlexServer.clients", side_effect=RequestException): @@ -146,7 +146,7 @@ async def test_mark_sessions_idle( active_sessions = mock_plex_server._plex_server.sessions() - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) url = mock_plex_server.url_in_use @@ -157,7 +157,7 @@ async def test_mark_sessions_idle( await hass.async_block_till_done() await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "0" @@ -175,7 +175,7 @@ async def test_ignore_plex_web_client( await wait_for_debouncer(hass) active_sessions = mock_plex_server._plex_server.sessions() - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) media_players = hass.states.async_entity_ids("media_player") diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index bc1bc9c8c0cf5d..37566e1d39ed76 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -4,11 +4,12 @@ "active_preset": "no_frost", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "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"], @@ -99,6 +100,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -155,6 +157,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -265,6 +268,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", @@ -300,6 +304,7 @@ "cooling_present": false, "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", "heater_id": "e4684553153b44afbef2200885f379dc", + "item_count": 219, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/adam_jip/device_list.json b/tests/components/plugwise/fixtures/adam_jip/device_list.json new file mode 100644 index 00000000000000..049845bc828538 --- /dev/null +++ b/tests/components/plugwise/fixtures/adam_jip/device_list.json @@ -0,0 +1,13 @@ +[ + "b5c2386c6f6342669e50fe49dd05b188", + "e4684553153b44afbef2200885f379dc", + "a6abc6a129ee499c88a4d420cc413b47", + "1346fbd8498d4dbcab7e18d51b771f3d", + "833de10f269c4deab58fb9df69901b4e", + "6f3e9d7084214c21b9dfa46f6eeb8700", + "f61f1a2535f54f52ad006a3d18e459ca", + "d4496250d0e942cfa7aea3476e9070d5", + "356b65335e274d769c338223e7af9c33", + "1da4d325838e4ad8aac12177214505c9", + "457ce8414de24596a2d5e7dbc9c7682f" +] diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 6e6da1aa272d3a..f97182782e6bef 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -112,7 +112,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", @@ -251,7 +252,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-08-02T02:00:00+02:00", @@ -334,7 +336,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", @@ -344,7 +347,7 @@ "model": "Lisa", "name": "Zone Lisa Bios", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", + "select_schedule": "off", "sensors": { "battery": 67, "setpoint": 13.0, @@ -373,7 +376,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", @@ -383,7 +387,7 @@ "model": "Tom/Floor", "name": "CV Kraan Garage", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", + "select_schedule": "off", "sensors": { "battery": 68, "setpoint": 5.5, @@ -414,7 +418,8 @@ "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie" + "CV Jessie", + "off" ], "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", @@ -468,6 +473,7 @@ "cooling_present": false, "gateway_id": "fe799307f1624099878210aa0b9f1475", "heater_id": "90986d591dcd426cae3ec3e8111ff730", + "item_count": 315, "notifications": { "af82e4ccf9c548528166d38e560662a4": { "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json new file mode 100644 index 00000000000000..104a723e4637b2 --- /dev/null +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json @@ -0,0 +1,20 @@ +[ + "fe799307f1624099878210aa0b9f1475", + "90986d591dcd426cae3ec3e8111ff730", + "df4a4a8169904cdb9c03d61a21f42140", + "b310b72a0e354bfab43089919b9a88bf", + "a2c3583e0a6349358998b760cea82d2a", + "b59bcebaf94b499ea7d46e4a66fb62d8", + "d3da73bde12a47d5a6b8f9dad971f2ec", + "21f2b542c49845e6bb416884c55778d6", + "78d1126fc4c743db81b61c20e88342a7", + "cd0ddb54ef694e11ac18ed1cbce5dbbd", + "4a810418d5394b3f82727340b91ba740", + "02cf28bfec924855854c544690a609ef", + "a28f588dc4a049a483fd03a30361ad3a", + "6a3bf693d05e48e0b460c815a4fdd09d", + "680423ff840043738f42cc7f1ff97a36", + "f1fee6043d3642a9b0a65297455f008e", + "675416a629f343c495449970e2ca37b5", + "e7693eb9582644e5b865dba8d4447cf1" +] 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 e7e13e17357d5f..d655f95c79b398 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, @@ -58,7 +59,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard"], + "available_schedules": ["standaard", "off"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", @@ -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/anna_heatpump_heating/device_list.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json new file mode 100644 index 00000000000000..ffb8cf62575a2a --- /dev/null +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] 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 126852e945d88d..7b570a6cf61f1d 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -52,25 +52,24 @@ "ad4838d7d35c4d6ea796ee12ae5aedf8": { "active_preset": "asleep", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], + "control_state": "cooling", "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" @@ -103,7 +102,8 @@ "e2f4322d57924fa090fbbc48b3a140dc": { "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -115,9 +115,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 +127,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", @@ -152,6 +150,7 @@ "cooling_present": true, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", + "item_count": 145, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json new file mode 100644 index 00000000000000..f78b4cd38a936a --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json @@ -0,0 +1,8 @@ +[ + "da224107914542988a88561b4452b0f6", + "056ee145a816487eaa69243c3280f8bf", + "ad4838d7d35c4d6ea796ee12ae5aedf8", + "1772a4ea304041adb83f357b751341ff", + "e2f4322d57924fa090fbbc48b3a140dc", + "e8ef2a01ed3b4139a53bf749204fe6b4" +] diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index e8a72c9b3fbfd9..57259047698035 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -1,5 +1,28 @@ { "devices": { + "01234567890abcdefghijklmnopqrstu": { + "available": false, + "dev_class": "thermo_sensor", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "temperature": 18.6, + "temperature_difference": 2.3, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, "056ee145a816487eaa69243c3280f8bf": { "available": true, "binary_sensors": { @@ -57,7 +80,8 @@ "ad4838d7d35c4d6ea796ee12ae5aedf8": { "active_preset": "asleep", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], + "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat", @@ -100,7 +124,8 @@ "e2f4322d57924fa090fbbc48b3a140dc": { "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test"], + "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -147,6 +172,7 @@ "cooling_present": false, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", + "item_count": 145, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/m_adam_heating/device_list.json b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json new file mode 100644 index 00000000000000..f78b4cd38a936a --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json @@ -0,0 +1,8 @@ +[ + "da224107914542988a88561b4452b0f6", + "056ee145a816487eaa69243c3280f8bf", + "ad4838d7d35c4d6ea796ee12ae5aedf8", + "1772a4ea304041adb83f357b751341ff", + "e2f4322d57924fa090fbbc48b3a140dc", + "e8ef2a01ed3b4139a53bf749204fe6b4" +] 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 40364e620c3f7c..92c95f6c5a9dd2 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 @@ -59,7 +59,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard"], + "available_schedules": ["standaard", "off"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", @@ -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_cooling/device_list.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json new file mode 100644 index 00000000000000..ffb8cf62575a2a --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] 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 3a84a59deea7a0..be400b9bc98476 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 @@ -59,7 +59,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard"], + "available_schedules": ["standaard", "off"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", @@ -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/fixtures/m_anna_heatpump_idle/device_list.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json new file mode 100644 index 00000000000000..ffb8cf62575a2a --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json index 0e0b3c51a07022..0a47893c077ad0 100644 --- a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json +++ b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json @@ -42,6 +42,7 @@ }, "gateway": { "gateway_id": "cd3e822288064775a7c4afcdd70bdda2", + "item_count": 31, "notifications": {}, "smile_name": "Smile P1" } diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json b/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json new file mode 100644 index 00000000000000..8af35165c7e3cb --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json @@ -0,0 +1 @@ +["cd3e822288064775a7c4afcdd70bdda2", "e950c7d5e1ee407a858e2a8b5016c8b3"] diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json index d503bd3a59d267..ecda80491632b3 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json @@ -51,6 +51,7 @@ }, "gateway": { "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", + "item_count": 40, "notifications": { "97a04c0c263049b29350a660b4cdd01e": { "warning": "The Smile P1 is not connected to a smart meter." diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json b/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json new file mode 100644 index 00000000000000..7b301f5092423d --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json @@ -0,0 +1 @@ +["03e65b16e4b247a29ae0d75a78cb492e", "b82b6b3322484f2ea4e25e0bd5f3d61f"] diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index 8604aaae10e6cc..6b1012b0d87bed 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -135,6 +135,7 @@ }, "gateway": { "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", + "item_count": 83, "notifications": {}, "smile_name": "Stretch" } diff --git a/tests/components/plugwise/fixtures/stretch_v31/device_list.json b/tests/components/plugwise/fixtures/stretch_v31/device_list.json new file mode 100644 index 00000000000000..b2c839ae9d301f --- /dev/null +++ b/tests/components/plugwise/fixtures/stretch_v31/device_list.json @@ -0,0 +1,10 @@ +[ + "0000aaaa0000aaaa0000aaaa0000aa00", + "5871317346d045bc9f6b987ef25ee638", + "e1c884e7dede431dadee09506ec4f859", + "aac7b735042c4832ac9ff33aae4f453b", + "cfe95cf3de1948c0b8955125bf754614", + "059e4d03c7a34d278add5c7a4a781d19", + "d950b314e9d8499f968e6db8d82ef78c", + "d03738edfcc947f7b8f4573571d90d2d" +] diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 597b9710ec5e1a..c2bbea9418a10f 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -115,6 +115,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', @@ -260,6 +261,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-08-02T02:00:00+02:00', @@ -349,6 +351,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', @@ -364,7 +367,7 @@ 'vacation', 'no_frost', ]), - 'select_schedule': 'None', + 'select_schedule': 'off', 'sensors': dict({ 'battery': 67, 'setpoint': 13.0, @@ -394,6 +397,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', @@ -409,7 +413,7 @@ 'vacation', 'no_frost', ]), - 'select_schedule': 'None', + 'select_schedule': 'off', 'sensors': dict({ 'battery': 68, 'setpoint': 5.5, @@ -441,6 +445,7 @@ 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', + 'off', ]), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', @@ -500,6 +505,7 @@ 'cooling_present': False, 'gateway_id': 'fe799307f1624099878210aa0b9f1475', 'heater_id': '90986d591dcd426cae3ec3e8111ff730', + 'item_count': 315, 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index d8ce2785f2a5e1..c5ab3a209c2b13 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -13,6 +13,10 @@ 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 @@ -61,14 +65,22 @@ async def test_adam_2_climate_entity_attributes( 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.HEAT, HVACMode.AUTO] + assert state.attributes["hvac_action"] == "preheating" + 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_action"] == "idle" + 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"]["control_state"] = "heating" + 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"]["control_state"] = "cooling" + 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 9df20a5ffc8d2d..f1220a07a2bbf5 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -16,7 +16,7 @@ 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/private_ble_device/__init__.py b/tests/components/private_ble_device/__init__.py index df9929293a1526..967f422872ba83 100644 --- a/tests/components/private_ble_device/__init__.py +++ b/tests/components/private_ble_device/__init__.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from unittest.mock import patch from home_assistant_bluetooth import BluetoothServiceInfoBleak @@ -16,6 +15,7 @@ generate_advertisement_data, generate_ble_device, inject_bluetooth_service_info_bleak, + patch_bluetooth_time, ) MAC_RPA_VALID_1 = "40:01:02:0a:c4:a6" @@ -70,9 +70,8 @@ async def async_inject_broadcast( async def async_move_time_forwards(hass: HomeAssistant, offset: float): """Mock time advancing from now to now+offset.""" - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=time.monotonic() + offset, + with patch_bluetooth_time( + time.monotonic() + offset, ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=offset)) await hass.async_block_till_done() diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py index d8b3073886519c..3834254ac7f175 100644 --- a/tests/components/private_ble_device/test_device_tracker.py +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -3,9 +3,8 @@ import time -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth.api import ( async_get_fallback_availability_interval, ) diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index 65f08d5653d2de..15e205c8c86dfd 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -1,10 +1,9 @@ """Tests for sensors.""" +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth import async_set_fallback_availability_interval -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.core import HomeAssistant from . import ( @@ -82,7 +81,7 @@ async def test_estimated_broadcast_interval( "sensor.private_ble_device_000000_estimated_broadcast_interval" ) assert state - assert state.state == "90" + assert state.state == "90.0" # Learned broadcast interval takes over from fallback interval @@ -95,7 +94,7 @@ async def test_estimated_broadcast_interval( "sensor.private_ble_device_000000_estimated_broadcast_interval" ) assert state - assert state.state == "10" + assert state.state == "10.0" # MAC address changes, the broadcast interval is kept @@ -105,4 +104,4 @@ async def test_estimated_broadcast_interval( "sensor.private_ble_device_000000_estimated_broadcast_interval" ) assert state - assert state.state == "10" + assert state.state == "10.0" diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 7c2aeb2a29acd5..b8a81a40e37cd1 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -5,7 +5,7 @@ from pathlib import Path from unittest.mock import patch -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU import pytest from homeassistant.components.profiler import ( diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index f24782b98d4d4e..af2f2ba57846bc 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -20,6 +20,7 @@ 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.""" @@ -466,6 +491,12 @@ async def test_light(client, light_entities) -> None: 'friendly_name="PC"} 70.58823529411765' in body ) + assert ( + 'light_brightness_percent{domain="light",' + 'entity="light.hallway",' + 'friendly_name="Hallway"} 100.0' in body + ) + @pytest.mark.parametrize("namespace", [""]) async def test_lock(client, lock_entities) -> None: @@ -1382,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 @@ -1492,6 +1563,19 @@ async def light_fixture( data["light_4"] = light_4 data["light_4_attributes"] = light_4_attributes + light_5 = entity_registry.async_get_or_create( + domain=light.DOMAIN, + platform="test", + unique_id="light_5", + suggested_object_id="hallway", + original_name="Hallway", + ) + # Light is on, but brightness is unset; expect metrics to report + # brightness of 100%. + light_5_attributes = {light.ATTR_BRIGHTNESS: None} + set_state_with_entry(hass, light_5, STATE_ON, light_5_attributes) + data["light_5"] = light_5 + data["light_5_attributes"] = light_5_attributes await hass.async_block_till_done() return data diff --git a/tests/components/prusalink/conftest.py b/tests/components/prusalink/conftest.py index 8beb67b0ed412d..1e514342068c2a 100644 --- a/tests/components/prusalink/conftest.py +++ b/tests/components/prusalink/conftest.py @@ -1,9 +1,10 @@ """Fixtures for PrusaLink.""" - from unittest.mock import patch import pytest +from homeassistant.components.prusalink import DOMAIN + from tests.common import MockConfigEntry @@ -11,7 +12,10 @@ def mock_config_entry(hass): """Mock a PrusaLink config entry.""" entry = MockConfigEntry( - domain="prusalink", data={"host": "http://example.com", "api_key": "abcdefgh"} + domain=DOMAIN, + data={"host": "http://example.com", "username": "dummy", "password": "dummypw"}, + version=1, + minor_version=2, ) entry.add_to_hass(hass) return entry @@ -23,96 +27,138 @@ def mock_version_api(hass): resp = { "api": "2.0.0", "server": "2.1.2", - "text": "PrusaLink MINI", - "hostname": "PrusaMINI", + "text": "PrusaLink", + "hostname": "PrusaXL", } with patch("pyprusalink.PrusaLink.get_version", return_value=resp): yield resp @pytest.fixture -def mock_printer_api(hass): +def mock_info_api(hass): + """Mock PrusaLink info API.""" + resp = { + "nozzle_diameter": 0.40, + "mmu": False, + "serial": "serial-1337", + "hostname": "PrusaXL", + "min_extrusion_temp": 170, + } + with patch("pyprusalink.PrusaLink.get_info", return_value=resp): + yield resp + + +@pytest.fixture +def mock_get_legacy_printer(hass): + """Mock PrusaLink printer API.""" + resp = {"telemetry": {"material": "PLA"}} + with patch("pyprusalink.PrusaLink.get_legacy_printer", return_value=resp): + yield resp + + +@pytest.fixture +def mock_get_status_idle(hass): """Mock PrusaLink printer API.""" resp = { - "telemetry": { - "temp-bed": 41.9, - "temp-nozzle": 47.8, - "print-speed": 100, - "z-height": 1.8, - "material": "PLA", + "storage": { + "path": "/usb/", + "name": "usb", + "read_only": False, }, - "temperature": { - "tool0": {"actual": 47.8, "target": 210.1, "display": 0.0, "offset": 0}, - "bed": {"actual": 41.9, "target": 60.5, "offset": 0}, + "printer": { + "state": "IDLE", + "temp_bed": 41.9, + "target_bed": 60.5, + "temp_nozzle": 47.8, + "target_nozzle": 210.1, + "axis_z": 1.8, + "axis_x": 7.9, + "axis_y": 8.4, + "flow": 100, + "speed": 100, + "fan_hotend": 100, + "fan_print": 75, }, - "state": { - "text": "Operational", - "flags": { - "operational": True, - "paused": False, - "printing": False, - "cancelling": False, - "pausing": False, - "sdReady": False, - "error": False, - "closedOnError": False, - "ready": True, - "busy": False, - }, + } + with patch("pyprusalink.PrusaLink.get_status", return_value=resp): + yield resp + + +@pytest.fixture +def mock_get_status_printing(hass): + """Mock PrusaLink printer API.""" + resp = { + "job": { + "id": 129, + "progress": 37.00, + "time_remaining": 73020, + "time_printing": 43987, + }, + "storage": {"path": "/usb/", "name": "usb", "read_only": False}, + "printer": { + "state": "PRINTING", + "temp_bed": 53.9, + "target_bed": 85.0, + "temp_nozzle": 6.0, + "target_nozzle": 0.0, + "axis_z": 5.0, + "flow": 100, + "speed": 100, + "fan_hotend": 5000, + "fan_print": 2500, }, } - with patch("pyprusalink.PrusaLink.get_printer", return_value=resp): + with patch("pyprusalink.PrusaLink.get_status", return_value=resp): yield resp @pytest.fixture def mock_job_api_idle(hass): """Mock PrusaLink job API having no job.""" - resp = { - "state": "Operational", - "job": None, - "progress": None, - } + resp = {} with patch("pyprusalink.PrusaLink.get_job", return_value=resp): yield resp @pytest.fixture -def mock_job_api_printing(hass, mock_printer_api, mock_job_api_idle): +def mock_job_api_printing(hass): """Mock PrusaLink printing.""" - mock_printer_api["state"]["text"] = "Printing" - mock_printer_api["state"]["flags"]["printing"] = True - - mock_job_api_idle.update( - { - "state": "Printing", - "job": { - "estimatedPrintTime": 117007, - "file": { - "name": "TabletStand3.gcode", - "path": "/usb/TABLET~1.GCO", - "display": "TabletStand3.gcode", - }, - }, - "progress": { - "completion": 0.37, - "printTime": 43987, - "printTimeLeft": 73020, + resp = { + "id": 129, + "state": "PRINTING", + "progress": 37.00, + "time_remaining": 73020, + "time_printing": 43987, + "file": { + "refs": { + "icon": "/thumb/s/usb/TabletStand3~4.BGC", + "thumbnail": "/thumb/l/usb/TabletStand3~4.BGC", + "download": "/usb/TabletStand3~4.BGC", }, - } - ) + "name": "TabletStand3~4.BGC", + "display_name": "TabletStand3.bgcode", + "path": "/usb", + "size": 754535, + "m_timestamp": 1698686881, + }, + } + with patch("pyprusalink.PrusaLink.get_job", return_value=resp): + yield resp @pytest.fixture -def mock_job_api_paused(hass, mock_printer_api, mock_job_api_idle): +def mock_job_api_paused(hass, mock_get_status_printing, mock_job_api_printing): """Mock PrusaLink paused printing.""" - mock_printer_api["state"]["text"] = "Paused" - mock_printer_api["state"]["flags"]["printing"] = False - mock_printer_api["state"]["flags"]["paused"] = True - - mock_job_api_idle["state"] = "Paused" + mock_job_api_printing["state"] = "PAUSED" + mock_get_status_printing["printer"]["state"] = "PAUSED" @pytest.fixture -def mock_api(mock_version_api, mock_printer_api, mock_job_api_idle): +def mock_api( + mock_version_api, + mock_info_api, + mock_get_legacy_printer, + mock_get_status_idle, + mock_job_api_idle, +): """Mock PrusaLink API.""" diff --git a/tests/components/prusalink/test_button.py b/tests/components/prusalink/test_button.py index 658587327dd662..5324e3377800e6 100644 --- a/tests/components/prusalink/test_button.py +++ b/tests/components/prusalink/test_button.py @@ -1,7 +1,7 @@ """Test Prusalink buttons.""" from unittest.mock import patch -from pyprusalink import Conflict +from pyprusalink.types import Conflict import pytest from homeassistant.const import Platform @@ -32,6 +32,7 @@ async def test_button_pause_cancel( mock_api, hass_client: ClientSessionGenerator, mock_job_api_printing, + mock_get_status_printing, object_id, method, ) -> None: @@ -66,9 +67,12 @@ async def test_button_pause_cancel( @pytest.mark.parametrize( ("object_id", "method"), - (("mock_title_resume_job", "resume_job"),), + ( + ("mock_title_cancel_job", "cancel_job"), + ("mock_title_resume_job", "resume_job"), + ), ) -async def test_button_resume( +async def test_button_resume_cancel( hass: HomeAssistant, mock_config_entry, mock_api, diff --git a/tests/components/prusalink/test_camera.py b/tests/components/prusalink/test_camera.py index 010758bcca8871..b84a13a3df80b6 100644 --- a/tests/components/prusalink/test_camera.py +++ b/tests/components/prusalink/test_camera.py @@ -49,13 +49,13 @@ async def test_camera_active_job( client = await hass_client() - with patch("pyprusalink.PrusaLink.get_large_thumbnail", return_value=b"hello"): + with patch("pyprusalink.PrusaLink.get_file", return_value=b"hello"): resp = await client.get("/api/camera_proxy/camera.mock_title_preview") assert resp.status == 200 assert await resp.read() == b"hello" # Make sure we hit cached value. - with patch("pyprusalink.PrusaLink.get_large_thumbnail", side_effect=ValueError): + with patch("pyprusalink.PrusaLink.get_file", side_effect=ValueError): resp = await client.get("/api/camera_proxy/camera.mock_title_preview") assert resp.status == 200 assert await resp.read() == b"hello" diff --git a/tests/components/prusalink/test_config_flow.py b/tests/components/prusalink/test_config_flow.py index 4810ea821662e0..6a23e05adf96df 100644 --- a/tests/components/prusalink/test_config_flow.py +++ b/tests/components/prusalink/test_config_flow.py @@ -25,16 +25,18 @@ async def test_form(hass: HomeAssistant, mock_version_api) -> None: result["flow_id"], { "host": "http://1.1.1.1/", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "PrusaMINI" + assert result2["title"] == "PrusaXL" assert result2["data"] == { "host": "http://1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", } assert len(mock_setup_entry.mock_calls) == 1 @@ -53,7 +55,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -75,7 +78,8 @@ async def test_form_unknown(hass: HomeAssistant) -> None: result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -95,7 +99,8 @@ async def test_form_too_low_version(hass: HomeAssistant, mock_version_api) -> No result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -115,7 +120,8 @@ async def test_form_invalid_version_2(hass: HomeAssistant, mock_version_api) -> result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -137,7 +143,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) diff --git a/tests/components/prusalink/test_init.py b/tests/components/prusalink/test_init.py index 543ee68d5ddaf6..5b261207e9340e 100644 --- a/tests/components/prusalink/test_init.py +++ b/tests/components/prusalink/test_init.py @@ -2,20 +2,25 @@ from datetime import timedelta from unittest.mock import patch -from pyprusalink import InvalidAuth, PrusaLinkError +from pyprusalink.types import InvalidAuth, PrusaLinkError import pytest +from homeassistant.components.prusalink import DOMAIN +from homeassistant.components.prusalink.config_flow import ConfigFlow from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("mock_api") async def test_unloading( hass: HomeAssistant, mock_config_entry: ConfigEntry, - mock_api, ) -> None: """Test unloading prusalink.""" assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -32,14 +37,20 @@ async def test_unloading( @pytest.mark.parametrize("exception", [InvalidAuth, PrusaLinkError]) async def test_failed_update( - hass: HomeAssistant, mock_config_entry: ConfigEntry, mock_api, exception + hass: HomeAssistant, mock_config_entry: ConfigEntry, exception ) -> None: """Test failed update marks prusalink unavailable.""" assert await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.prusalink.PrusaLink.get_printer", + "homeassistant.components.prusalink.PrusaLink.get_version", + side_effect=exception, + ), patch( + "homeassistant.components.prusalink.PrusaLink.get_status", + side_effect=exception, + ), patch( + "homeassistant.components.prusalink.PrusaLink.get_legacy_printer", side_effect=exception, ), patch( "homeassistant.components.prusalink.PrusaLink.get_job", @@ -50,3 +61,86 @@ async def test_failed_update( for state in hass.states.async_all(): assert state.state == "unavailable" + + +async def test_migration_from_1_1_to_1_2( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test migrating from version 1 to 2.""" + data = { + CONF_HOST: "http://prusaxl.local", + CONF_API_KEY: "api-key", + } + entry = MockConfigEntry( + domain=DOMAIN, + data=data, + version=1, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + + # Ensure that we have username, password after migration + assert len(config_entries) == 1 + assert config_entries[0].data == { + **data, + CONF_USERNAME: "maker", + CONF_PASSWORD: "api-key", + } + # Make sure that we don't have any issues + assert len(issue_registry.issues) == 0 + + +async def test_migration_from_1_1_to_1_2_outdated_firmware( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test migrating from version 1.1 to 1.2.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "http://prusaxl.local", + CONF_API_KEY: "api-key", + }, + version=1, + ) + entry.add_to_hass(hass) + + with patch( + "pyprusalink.PrusaLink.get_info", + side_effect=InvalidAuth, # Simulate firmware update required + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.SETUP_ERROR + assert entry.minor_version == 1 + assert (DOMAIN, "firmware_5_1_required") in issue_registry.issues + + # Reloading the integration with a working API (e.g. User updated firmware) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Integration should be running now, the issue should be gone + assert entry.state == ConfigEntryState.LOADED + assert entry.minor_version == 2 + assert (DOMAIN, "firmware_5_1_required") not in issue_registry.issues + + +async def test_migration_fails_on_future_version( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test migrating fails on a version higher than the current one.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + version=ConfigFlow.VERSION + 1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/prusalink/test_sensor.py b/tests/components/prusalink/test_sensor.py index 0f2a966b4e4a0e..366f2d3abc895a 100644 --- a/tests/components/prusalink/test_sensor.py +++ b/tests/components/prusalink/test_sensor.py @@ -15,6 +15,7 @@ ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, Platform, UnitOfLength, UnitOfTemperature, @@ -44,11 +45,15 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state.state == "idle" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM assert state.attributes[ATTR_OPTIONS] == [ - "cancelling", "idle", - "paused", - "pausing", + "busy", "printing", + "paused", + "finished", + "stopped", + "error", + "attention", + "ready", ] state = hass.states.get("sensor.mock_title_heatbed_temperature") @@ -95,6 +100,11 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state is not None assert state.state == "PLA" + state = hass.states.get("sensor.mock_title_print_flow") + assert state is not None + assert state.state == "100" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + state = hass.states.get("sensor.mock_title_progress") assert state is not None assert state.state == "unavailable" @@ -114,12 +124,22 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state.state == "unavailable" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.mock_title_hotend_fan") + assert state is not None + assert state.state == "100" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + + state = hass.states.get("sensor.mock_title_print_fan") + assert state is not None + assert state.state == "75" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + async def test_sensors_active_job( hass: HomeAssistant, mock_config_entry, mock_api, - mock_printer_api, + mock_get_status_printing, mock_job_api_printing, ) -> None: """Test sensors while active job.""" @@ -140,7 +160,7 @@ async def test_sensors_active_job( state = hass.states.get("sensor.mock_title_filename") assert state is not None - assert state.state == "TabletStand3.gcode" + assert state.state == "TabletStand3.bgcode" state = hass.states.get("sensor.mock_title_print_start") assert state is not None @@ -151,3 +171,13 @@ async def test_sensors_active_job( assert state is not None assert state.state == "2022-08-28T10:17:00+00:00" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + + state = hass.states.get("sensor.mock_title_hotend_fan") + assert state is not None + assert state.state == "5000" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + + state = hass.states.get("sensor.mock_title_print_fan") + assert state is not None + assert state.state == "2500" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index 3b8dc5e1e247b7..1252348b3e000d 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -44,6 +44,7 @@ MOCK_FLOW_RESULT = { "version": VERSION, + "minor_version": 1, "handler": DOMAIN, "type": data_entry_flow.FlowResultType.CREATE_ENTRY, "title": "test_ps4", diff --git a/tests/components/purpleair/test_diagnostics.py b/tests/components/purpleair/test_diagnostics.py index 35dc515241c0ba..85b078d0765c77 100644 --- a/tests/components/purpleair/test_diagnostics.py +++ b/tests/components/purpleair/test_diagnostics.py @@ -17,6 +17,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": "purpleair", "title": REDACTED, "data": { diff --git a/tests/components/pvpc_hourly_pricing/conftest.py b/tests/components/pvpc_hourly_pricing/conftest.py index fb2c9188ce7225..3bf1b08a51d1a4 100644 --- a/tests/components/pvpc_hourly_pricing/conftest.py +++ b/tests/components/pvpc_hourly_pricing/conftest.py @@ -10,6 +10,8 @@ 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" +_ESIOS_INDICATORS_FOR_EACH_SENSOR = ("1001", "1739", "1900", "10211") def check_valid_state(state, tariff: str, value=None, key_attr=None): @@ -21,7 +23,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 +43,45 @@ 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/{0}" + "?start_date={1}T00:00&end_date={1}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}"), ) + for esios_ind in _ESIOS_INDICATORS_FOR_EACH_SENSOR: + aioclient_mock.get( + mask_url_esios.format(esios_ind, 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, - text=( - '{"errors":[{"code":502,"status":"502","title":"Bad response from ESIOS",' - '"detail":"There are no data for the selected filters."}]}' - ), + status=HTTPStatus.OK, + text='{"message":"No values for specified archive"}', ) + for esios_ind in _ESIOS_INDICATORS_FOR_EACH_SENSOR: + aioclient_mock.get( + mask_url_esios.format(esios_ind, "2023-01-07"), + status=HTTPStatus.OK, + text=( + '{"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":[]}}' + ).replace("1001", esios_ind), + ) + # simulate bad authentication + for esios_ind in _ESIOS_INDICATORS_FOR_EACH_SENSOR: + aioclient_mock.get( + mask_url_esios.format(esios_ind, "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 00000000000000..20ad8af3696fc1 --- /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 6560c81ebbb86a..087edcc1557631 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 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.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( @@ -61,6 +64,10 @@ async def test_config_flow( check_valid_state(state, tariff=TARIFFS[1]) assert pvpc_aioclient_mock.call_count == 1 + # no extra sensors created without enabled API token + state_inyection = hass.states.get("sensor.injection_price") + assert state_inyection is None + # Check abort when configuring another with same tariff result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -107,16 +114,34 @@ 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: "test-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]) - assert pvpc_aioclient_mock.call_count == 3 + assert pvpc_aioclient_mock.call_count == 4 assert state.attributes["period"] == "P3" assert state.attributes["next_period"] == "P2" assert state.attributes["available_power"] == 4600 + state_inyection = hass.states.get("sensor.esios_injection_price") + state_mag = hass.states.get("sensor.esios_mag_tax") + state_omie = hass.states.get("sensor.esios_omie_price") + assert state_inyection + assert not state_mag + assert not state_omie + assert "period" not in state_inyection.attributes + assert "available_power" not in state_inyection.attributes + # check update failed freezer.tick(timedelta(days=1)) async_fire_time_changed(hass) @@ -124,4 +149,107 @@ async def test_config_flow( state = hass.states.get("sensor.esios_pvpc") check_valid_state(state, tariff=TARIFFS[0], value="unavailable") assert "period" not in state.attributes + assert pvpc_aioclient_mock.call_count == 6 + + # 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 == 6 + await hass.async_block_till_done() + assert pvpc_aioclient_mock.call_count == 7 + + state = hass.states.get("sensor.esios_pvpc") + state_inyection = hass.states.get("sensor.esios_injection_price") + state_mag = hass.states.get("sensor.esios_mag_tax") + state_omie = hass.states.get("sensor.esios_omie_price") + check_valid_state(state, tariff=TARIFFS[1]) + assert state_inyection.state == "unavailable" + assert not state_mag + assert not state_omie + + +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: "test-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: "test-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + config_entry = result["result"] assert pvpc_aioclient_mock.call_count == 4 + + # 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 == 6 + + 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: "test-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert pvpc_aioclient_mock.call_count == 7 + + 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: "test-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert pvpc_aioclient_mock.call_count == 8 + + await hass.async_block_till_done() + assert pvpc_aioclient_mock.call_count == 10 diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 9326869b272974..4744c065ede0e9 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/qingping/test_binary_sensor.py b/tests/components/qingping/test_binary_sensor.py index 9b83cd8c59074d..f201b3b55ff256 100644 --- a/tests/components/qingping/test_binary_sensor.py +++ b/tests/components/qingping/test_binary_sensor.py @@ -1,7 +1,6 @@ """Test the Qingping binary sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -17,6 +16,7 @@ from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -72,9 +72,8 @@ async def test_binary_sensor_restore_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/qingping/test_sensor.py b/tests/components/qingping/test_sensor.py index 2fedbba9e5c3b8..12e3ec85c52452 100644 --- a/tests/components/qingping/test_sensor.py +++ b/tests/components/qingping/test_sensor.py @@ -1,7 +1,6 @@ """Test the Qingping sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -22,6 +21,7 @@ from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -82,9 +82,8 @@ async def test_binary_sensor_restore_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index 18b33a6ef0cd27..5a9821dd52dfeb 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -2,6 +2,8 @@ import datetime from unittest.mock import MagicMock, call, patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.qld_bushfire.geo_location import ( @@ -70,7 +72,7 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -88,11 +90,10 @@ async def test_setup(hass: HomeAssistant) -> None: mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (38.2, -3.2)) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (38.3, -3.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "georss_qld_bushfire_alert_client.QldBushfireAlertFeed" - ) as mock_feed: + freezer.move_to(utcnow) + + with patch("georss_qld_bushfire_alert_client.QldBushfireAlertFeed") as mock_feed: mock_feed.return_value.update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index f7bdf232c9e79e..47204ebf537aa0 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -102,6 +102,18 @@ def mock_connection( ) +def mock_calendar( + aioclient_mock: AiohttpClientMocker, + url: str = URL, +) -> None: + """Mock radarr connection.""" + aioclient_mock.get( + f"{url}/api/v3/calendar", + text=load_fixture("radarr/calendar.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + def mock_connection_error( aioclient_mock: AiohttpClientMocker, url: str = URL, @@ -120,6 +132,7 @@ def mock_connection_invalid_auth( aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/rootfolder", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/calendar", status=HTTPStatus.UNAUTHORIZED) def mock_connection_server_error( @@ -136,6 +149,9 @@ def mock_connection_server_error( aioclient_mock.get( f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR ) + aioclient_mock.get( + f"{url}/api/v3/calendar", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) async def setup_integration( @@ -172,6 +188,8 @@ async def setup_integration( single_return=single_return, ) + mock_calendar(aioclient_mock, url) + if not skip_entry_setup: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/radarr/fixtures/calendar.json b/tests/components/radarr/fixtures/calendar.json new file mode 100644 index 00000000000000..2bf0338d6398bc --- /dev/null +++ b/tests/components/radarr/fixtures/calendar.json @@ -0,0 +1,111 @@ +[ + { + "title": "test", + "originalTitle": "string", + "alternateTitles": [], + "secondaryYearSourceId": 0, + "sortTitle": "string", + "sizeOnDisk": 0, + "status": "string", + "overview": "test2", + "physicalRelease": "2021-12-03T00:00:00Z", + "digitalRelease": "2020-08-11T00:00:00Z", + "images": [ + { + "coverType": "poster", + "url": "string" + } + ], + "website": "string", + "year": 0, + "hasFile": true, + "youTubeTrailerId": "string", + "studio": "string", + "path": "string", + "qualityProfileId": 0, + "monitored": true, + "minimumAvailability": "string", + "isAvailable": true, + "folderName": "string", + "runtime": 0, + "cleanTitle": "string", + "imdbId": "string", + "tmdbId": 0, + "titleSlug": "0", + "genres": ["string"], + "tags": [], + "added": "2020-07-16T13:25:37Z", + "ratings": { + "imdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "tmdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "metacritic": { + "votes": 0, + "value": 0, + "type": "string" + }, + "rottenTomatoes": { + "votes": 0, + "value": 0, + "type": "string" + } + }, + "movieFile": { + "movieId": 0, + "relativePath": "string", + "path": "string", + "size": 0, + "dateAdded": "2021-06-01T04:08:20Z", + "sceneName": "string", + "indexerFlags": 0, + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": false + } + }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 0.0, + "audioCodec": "string", + "audioLanguages": "string", + "audioStreamCount": 0, + "videoBitDepth": 0, + "videoBitrate": 0, + "videoCodec": "string", + "videoFps": 0.0, + "resolution": "string", + "runTime": "00:00:00", + "scanType": "string", + "subtitles": "string" + }, + "originalFilePath": "string", + "qualityCutoffNotMet": false, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "releaseGroup": "string", + "edition": "string", + "id": 0 + }, + "id": 0 + } +] diff --git a/tests/components/radarr/test_binary_sensor.py b/tests/components/radarr/test_binary_sensor.py index b6303de4a48513..cd1df721d5f076 100644 --- a/tests/components/radarr/test_binary_sensor.py +++ b/tests/components/radarr/test_binary_sensor.py @@ -1,4 +1,6 @@ """The tests for Radarr binary sensor platform.""" +import pytest + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_binary_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_calendar.py b/tests/components/radarr/test_calendar.py new file mode 100644 index 00000000000000..61e9bc27c9ba50 --- /dev/null +++ b/tests/components/radarr/test_calendar.py @@ -0,0 +1,41 @@ +"""The tests for Radarr calendar platform.""" +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.radarr.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_calendar( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for successfully setting up the Radarr platform.""" + freezer.move_to("2021-12-02 00:00:00-08:00") + entry = await setup_integration(hass, aioclient_mock) + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_ON + assert state.attributes.get("all_day") is True + assert state.attributes.get("description") == "test2" + assert state.attributes.get("end_time") == "2021-12-03 00:00:00" + assert state.attributes.get("message") == "test" + assert state.attributes.get("release_type") == "physicalRelease" + assert state.attributes.get("start_time") == "2021-12-02 00:00:00" + + freezer.tick(timedelta(hours=16)) + await coordinator.async_refresh() + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_OFF + assert len(state.attributes) == 1 + assert state.attributes.get("release_type") is None diff --git a/tests/components/radarr/test_config_flow.py b/tests/components/radarr/test_config_flow.py index 5527e311114638..5eab7c02bb992d 100644 --- a/tests/components/radarr/test_config_flow.py +++ b/tests/components/radarr/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from aiopyarr import exceptions +import pytest from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -135,6 +136,7 @@ async def test_zero_conf(hass: HomeAssistant) -> None: assert result["data"] == CONF_DATA +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index f16e5895633f50..62660c128744e7 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -1,4 +1,6 @@ """Test Radarr integration.""" +import pytest + from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -9,6 +11,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test unload.""" entry = await setup_integration(hass, aioclient_mock) @@ -43,6 +46,7 @@ async def test_async_setup_entry_auth_failed( assert not hass.data.get(DOMAIN) +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_device_info( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 90ab683037b91a..11f55b712cd63c 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -14,6 +14,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") @pytest.mark.parametrize( ("windows", "single", "root_folder"), [ @@ -65,6 +66,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_windows( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 04e423a399c65b..44baf09fd55de0 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -115,7 +115,7 @@ def mock_insert_schedule_response( @pytest.fixture(name="get_events") def get_events_fixture( - hass_client: Callable[..., Awaitable[ClientSession]] + hass_client: Callable[..., Awaitable[ClientSession]], ) -> GetEventsFn: """Fetch calendar events from the HTTP API.""" @@ -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 db9c4c8739ebc3..00cbefc65561b9 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.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 685f307d197488..2697e908c94a76 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/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 5fa457bf771a01..631f1d5a3f8767 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -8,6 +8,7 @@ from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components import zeroconf from homeassistant.components.rainmachine import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_USE_APP_RUN_TIMES, DOMAIN, @@ -106,12 +107,17 @@ async def test_options_flow(hass: HomeAssistant, config, config_entry) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_DEFAULT_ZONE_RUN_TIME: 600, CONF_USE_APP_RUN_TIMES: False}, + user_input={ + CONF_DEFAULT_ZONE_RUN_TIME: 600, + CONF_USE_APP_RUN_TIMES: False, + CONF_ALLOW_INACTIVE_ZONES_TO_RUN: False, + }, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == { CONF_DEFAULT_ZONE_RUN_TIME: 600, CONF_USE_APP_RUN_TIMES: False, + CONF_ALLOW_INACTIVE_ZONES_TO_RUN: False, } diff --git a/tests/components/rainmachine/test_diagnostics.py b/tests/components/rainmachine/test_diagnostics.py index 9c3aa6cd7dedc4..2180bf2a20e054 100644 --- a/tests/components/rainmachine/test_diagnostics.py +++ b/tests/components/rainmachine/test_diagnostics.py @@ -2,6 +2,7 @@ from regenmaschine.errors import RainMachineError from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.rainmachine.const import DEFAULT_ZONE_RUN from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -19,6 +20,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "rainmachine", "title": "Mock Title", "data": { @@ -27,7 +29,11 @@ async def test_entry_diagnostics( "port": 8080, "ssl": True, }, - "options": {"use_app_run_times": False}, + "options": { + "zone_run_time": DEFAULT_ZONE_RUN, + "use_app_run_times": False, + "allow_inactive_zones_to_run": False, + }, "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", @@ -645,6 +651,7 @@ async def test_entry_diagnostics_failed_controller_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "rainmachine", "title": "Mock Title", "data": { @@ -653,7 +660,11 @@ async def test_entry_diagnostics_failed_controller_diagnostics( "port": 8080, "ssl": True, }, - "options": {"use_app_run_times": False}, + "options": { + "zone_run_time": DEFAULT_ZONE_RUN, + "use_app_run_times": False, + "allow_inactive_zones_to_run": False, + }, "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index e905f4a5606540..69ff1596d7ccdb 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -19,6 +19,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "recollect_waste", "title": REDACTED, "data": {"place_id": REDACTED, "service_id": TEST_SERVICE_ID}, diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index a982eeb39be394..d0ed6f15d4302e 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_history.py b/tests/components/recorder/test_history.py index 21016a65cc2ed9..21af6b011829e4 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -118,7 +118,7 @@ def _add_db_entries( def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" hass = hass_recorder() @@ -246,7 +246,7 @@ def set_state(state): def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period descending.""" hass = hass_recorder() @@ -410,7 +410,7 @@ def set_state(state): def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Ensure a state can pass though copy(). @@ -455,7 +455,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -554,7 +554,7 @@ def test_get_significant_states_with_initial( def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -588,7 +588,7 @@ def test_get_significant_states_without_initial( def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -604,7 +604,7 @@ def test_get_significant_states_entity_id( def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -626,7 +626,7 @@ def test_get_significant_states_multiple_entity_ids( def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test order of results from get_significant_states. @@ -644,7 +644,7 @@ def test_get_significant_states_are_ordered( def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test significant states when significant_states_only is set.""" hass = hass_recorder() @@ -1082,7 +1082,7 @@ def _fetch_db_states() -> list[States]: def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period with multiple entities in the same test. @@ -1141,7 +1141,7 @@ def _get_entries(): def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for get_significant_states.""" hass = hass_recorder() @@ -1151,7 +1151,7 @@ def test_get_significant_states_without_entity_ids_raises( def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for state_changes_during_period.""" hass = hass_recorder() @@ -1161,7 +1161,7 @@ def test_state_changes_during_period_without_entity_ids_raises( def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test passing filters is no longer supported.""" hass = hass_recorder() @@ -1173,7 +1173,7 @@ def test_get_significant_states_with_filters_raises( def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -1182,7 +1182,7 @@ def test_get_significant_states_with_non_existent_entity_ids_returns_empty( def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -1193,7 +1193,7 @@ def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index 0ed6061de98235..4f75dc15b15032 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -37,7 +37,7 @@ def db_schema_30(): def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" hass = hass_recorder() @@ -152,7 +152,7 @@ def set_state(state): def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period descending.""" hass = hass_recorder() @@ -240,7 +240,7 @@ def set_state(state): def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Ensure a state can pass though copy(). @@ -293,7 +293,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -362,7 +362,7 @@ def test_get_significant_states_minimal_response( def test_get_significant_states_with_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -399,7 +399,7 @@ def test_get_significant_states_with_initial( def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -435,7 +435,7 @@ def test_get_significant_states_without_initial( def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -453,7 +453,7 @@ def test_get_significant_states_entity_id( def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -480,7 +480,7 @@ def test_get_significant_states_multiple_entity_ids( def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test order of results from get_significant_states. @@ -501,7 +501,7 @@ def test_get_significant_states_are_ordered( def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test significant states when significant_states_only is set.""" hass = hass_recorder() @@ -644,7 +644,7 @@ def set_state(entity_id, state, **kwargs): def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period with multiple entities in the same test. @@ -669,7 +669,7 @@ def test_state_changes_during_period_multiple_entities_single_test( def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for get_significant_states.""" hass = hass_recorder() @@ -679,7 +679,7 @@ def test_get_significant_states_without_entity_ids_raises( def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for state_changes_during_period.""" hass = hass_recorder() @@ -689,7 +689,7 @@ def test_state_changes_during_period_without_entity_ids_raises( def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test passing filters is no longer supported.""" hass = hass_recorder() @@ -701,7 +701,7 @@ def test_get_significant_states_with_filters_raises( def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -710,7 +710,7 @@ def test_get_significant_states_with_non_existent_entity_ids_returns_empty( def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -721,7 +721,7 @@ def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 5b721cd4c87ed3..477c13d61669ca 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -37,7 +37,7 @@ def db_schema_32(): def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" hass = hass_recorder() @@ -152,7 +152,7 @@ def set_state(state): def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period descending.""" hass = hass_recorder() @@ -239,7 +239,7 @@ def set_state(state): def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Ensure a state can pass though copy(). @@ -292,7 +292,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -389,7 +389,7 @@ def test_get_significant_states_with_initial( def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -425,7 +425,7 @@ def test_get_significant_states_without_initial( def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -443,7 +443,7 @@ def test_get_significant_states_entity_id( def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -470,7 +470,7 @@ def test_get_significant_states_multiple_entity_ids( def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test order of results from get_significant_states. @@ -491,7 +491,7 @@ def test_get_significant_states_are_ordered( def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test significant states when significant_states_only is set.""" hass = hass_recorder() @@ -634,7 +634,7 @@ def set_state(entity_id, state, **kwargs): def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period with multiple entities in the same test. @@ -659,7 +659,7 @@ def test_state_changes_during_period_multiple_entities_single_test( def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for get_significant_states.""" hass = hass_recorder() @@ -669,7 +669,7 @@ def test_get_significant_states_without_entity_ids_raises( def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for state_changes_during_period.""" hass = hass_recorder() @@ -679,7 +679,7 @@ def test_state_changes_during_period_without_entity_ids_raises( def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test passing filters is no longer supported.""" hass = hass_recorder() @@ -691,7 +691,7 @@ def test_get_significant_states_with_filters_raises( def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -700,7 +700,7 @@ def test_get_significant_states_with_non_existent_entity_ids_returns_empty( def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -711,7 +711,7 @@ def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 0dfbb6005c43f6..a9a12d72c415fd 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -537,7 +537,7 @@ def event_listener(event): def test_saving_state_with_commit_interval_zero( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving a state with a commit interval of zero.""" hass = hass_recorder({"commit_interval": 0}) @@ -594,7 +594,7 @@ def test_setup_without_migration(hass_recorder: Callable[..., HomeAssistant]) -> def test_saving_state_include_domains( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"include": {"domains": "test2"}}) @@ -604,7 +604,7 @@ def test_saving_state_include_domains( def test_saving_state_include_domains_globs( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -627,7 +627,7 @@ def test_saving_state_include_domains_globs( def test_saving_state_incl_entities( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"include": {"entities": "test2.recorder"}}) @@ -688,7 +688,7 @@ def _get_events(hass: HomeAssistant, event_types: list[str]) -> list[Event]: def test_saving_state_exclude_domains( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"exclude": {"domains": "test"}}) @@ -698,7 +698,7 @@ def test_saving_state_exclude_domains( def test_saving_state_exclude_domains_globs( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -712,7 +712,7 @@ def test_saving_state_exclude_domains_globs( def test_saving_state_exclude_entities( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"exclude": {"entities": "test.recorder"}}) @@ -722,7 +722,7 @@ def test_saving_state_exclude_entities( def test_saving_state_exclude_domain_include_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -733,7 +733,7 @@ def test_saving_state_exclude_domain_include_entity( def test_saving_state_exclude_domain_glob_include_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -749,7 +749,7 @@ def test_saving_state_exclude_domain_glob_include_entity( def test_saving_state_include_domain_exclude_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -762,7 +762,7 @@ def test_saving_state_include_domain_exclude_entity( def test_saving_state_include_domain_glob_exclude_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -780,7 +780,7 @@ def test_saving_state_include_domain_glob_exclude_entity( def test_saving_state_and_removing_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving the state of a removed entity.""" hass = hass_recorder() @@ -1025,7 +1025,7 @@ def test_auto_purge(hass_recorder: Callable[..., HomeAssistant]) -> None: @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_auto_repack_on_second_sunday( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does a repack on the 2nd sunday.""" hass = hass_recorder() @@ -1065,7 +1065,7 @@ def test_auto_purge_auto_repack_on_second_sunday( @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_auto_repack_disabled_on_second_sunday( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does not auto repack on the 2nd sunday if disabled.""" hass = hass_recorder({CONF_AUTO_REPACK: False}) @@ -1105,7 +1105,7 @@ def test_auto_purge_auto_repack_disabled_on_second_sunday( @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_no_auto_repack_on_not_second_sunday( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does not do a repack unless its the 2nd sunday.""" hass = hass_recorder() @@ -1431,7 +1431,7 @@ def test_has_services(hass_recorder: Callable[..., HomeAssistant]) -> None: def test_service_disable_events_not_recording( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that events are not recorded when recorder is disabled using service.""" hass = hass_recorder() @@ -1515,7 +1515,7 @@ def event_listener(event): def test_service_disable_states_not_recording( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that state changes are not recorded when recorder is disabled using service.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 852419559b2360..b9d0801d788982 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 4faa8dc7e8a346..1696c9018b4159 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 f386fd19e36aa5..e8f9130165f7b4 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_statistics.py b/tests/components/recorder/test_statistics.py index 03dc7b84caa570..69b7f9316f7b98 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -432,7 +432,7 @@ def rename_entry(): def test_statistics_during_period_set_back_compat( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test statistics_during_period can handle a list instead of a set.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index a7b15a7f12d38c..66daced2ca8148 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -25,6 +25,7 @@ process_timestamp, ) from homeassistant.components.recorder.util import ( + chunked_or_all, end_incomplete_runs, is_second_sunday, resolve_period, @@ -882,7 +883,7 @@ def test_build_mysqldb_conv() -> None: @patch("homeassistant.components.recorder.util.QUERY_RETRY_WAIT", 0) def test_execute_stmt_lambda_element( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test executing with execute_stmt_lambda_element.""" hass = hass_recorder() @@ -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 98f401e45d82c6..b11cc67707f6b1 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -98,13 +98,9 @@ def _get_states_index_names(): 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 @@ def _get_states_index_names(): 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 b371d69fe5f158..323b81211d797b 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2227,9 +2227,7 @@ def stalled_migration(*args): ), 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/refoss/__init__.py b/tests/components/refoss/__init__.py new file mode 100644 index 00000000000000..34df1b41714f0e --- /dev/null +++ b/tests/components/refoss/__init__.py @@ -0,0 +1,107 @@ +"""Common helpers for refoss test cases.""" +import asyncio +import logging +from unittest.mock import AsyncMock, Mock + +from refoss_ha.discovery import Listener + +from homeassistant.components.refoss.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class FakeDiscovery: + """Mock class replacing refoss device discovery.""" + + def __init__(self) -> None: + """Initialize the class.""" + self.mock_devices = {"abc": build_device_mock()} + self.last_mock_infos = {} + self._listeners = [] + + def add_listener(self, listener: Listener) -> None: + """Add an event listener.""" + self._listeners.append(listener) + + async def initialize(self) -> None: + """Initialize socket server.""" + self.sock = Mock() + + async def broadcast_msg(self, wait_for: int = 0): + """Search for devices, return mocked data.""" + + mock_infos = self.mock_devices + last_mock_infos = self.last_mock_infos + + new_infos = [] + updated_infos = [] + + for info in mock_infos.values(): + uuid = info.uuid + if uuid not in last_mock_infos: + new_infos.append(info) + else: + last_info = self.last_mock_infos[uuid] + if info.inner_ip != last_info.inner_ip: + updated_infos.append(info) + + self.last_mock_infos = mock_infos + for listener in self._listeners: + [await listener.device_found(x) for x in new_infos] + [await listener.device_update(x) for x in updated_infos] + + if wait_for: + await asyncio.sleep(wait_for) + + return new_infos + + +def build_device_mock(name="r10", ip="1.1.1.1", mac="aabbcc112233"): + """Build mock device object.""" + mock = Mock( + uuid="abc", + dev_name=name, + device_type="r10", + fmware_version="1.1.1", + hdware_version="1.1.2", + inner_ip=ip, + port="80", + mac=mac, + sub_type="eu", + channels=[0], + ) + return mock + + +def build_base_device_mock(name="r10", ip="1.1.1.1", mac="aabbcc112233"): + """Build mock base device object.""" + mock = Mock( + device_info=build_device_mock(name=name, ip=ip, mac=mac), + uuid="abc", + dev_name=name, + device_type="r10", + fmware_version="1.1.1", + hdware_version="1.1.2", + inner_ip=ip, + port="80", + mac=mac, + sub_type="eu", + channels=[0], + async_handle_update=AsyncMock(), + async_turn_on=AsyncMock(), + async_turn_off=AsyncMock(), + async_toggle=AsyncMock(), + ) + mock.status = {0: True} + return mock + + +async def async_setup_refoss(hass: HomeAssistant) -> MockConfigEntry: + """Set up the refoss platform.""" + entry = MockConfigEntry(domain=DOMAIN) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/refoss/conftest.py b/tests/components/refoss/conftest.py new file mode 100644 index 00000000000000..2fc695bbb2e2ea --- /dev/null +++ b/tests/components/refoss/conftest.py @@ -0,0 +1,14 @@ +"""Pytest module configuration.""" +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.refoss.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/refoss/test_config_flow.py b/tests/components/refoss/test_config_flow.py new file mode 100644 index 00000000000000..2a5842ffe46f21 --- /dev/null +++ b/tests/components/refoss/test_config_flow.py @@ -0,0 +1,65 @@ +"""Tests for the refoss Integration.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.refoss.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import FakeDiscovery, build_base_device_mock + + +@patch("homeassistant.components.refoss.config_flow.DISCOVERY_TIMEOUT", 0) +async def test_creating_entry_sets_up( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test setting up refoss.""" + with patch( + "homeassistant.components.refoss.util.Discovery", + return_value=FakeDiscovery(), + ), patch( + "homeassistant.components.refoss.bridge.async_build_base_device", + return_value=build_base_device_mock(), + ), patch( + "homeassistant.components.refoss.switch.isinstance", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +@patch("homeassistant.components.refoss.config_flow.DISCOVERY_TIMEOUT", 0) +async def test_creating_entry_has_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test setting up Refoss no devices.""" + with patch( + "homeassistant.components.refoss.util.Discovery", + return_value=FakeDiscovery(), + ) as discovery: + discovery.return_value.mock_devices = {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 6219943693b89e..a75ff85848362f 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -1,4 +1,6 @@ """The tests for the Remote component, adapted from Light Test.""" +import pytest + import homeassistant.components.remote as remote from homeassistant.components.remote import ( ATTR_ALTERNATIVE, @@ -20,7 +22,7 @@ ) from homeassistant.core import HomeAssistant -from tests.common import async_mock_service +from tests.common import async_mock_service, import_and_test_deprecated_constant_enum TEST_PLATFORM = {DOMAIN: {CONF_PLATFORM: "test"}} SERVICE_SEND_COMMAND = "send_command" @@ -139,3 +141,32 @@ async def test_delete_command(hass: HomeAssistant) -> None: assert call.domain == remote.DOMAIN assert call.service == SERVICE_DELETE_COMMAND assert call.data[ATTR_ENTITY_ID] == ENTITY_ID + + +@pytest.mark.parametrize(("enum"), list(remote.RemoteEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: remote.RemoteEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, remote, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockRemote(remote.RemoteEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockRemote() + assert entity.supported_features_compat is remote.RemoteEntityFeature(1) + assert "MockRemote" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "RemoteEntityFeature.LEARN_COMMAND" in caplog.text + caplog.clear() + assert entity.supported_features_compat is remote.RemoteEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/remote/test_significant_change.py b/tests/components/remote/test_significant_change.py new file mode 100644 index 00000000000000..dcbfce213d65ec --- /dev/null +++ b/tests/components/remote/test_significant_change.py @@ -0,0 +1,62 @@ +"""Test the Remote significant change platform.""" +from homeassistant.components.remote import ATTR_ACTIVITY_LIST, ATTR_CURRENT_ACTIVITY +from homeassistant.components.remote.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change() -> None: + """Detect Remote significant changes.""" + # no change at all + attrs = { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + } + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + + # change of state is significant + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + # change of current activity is significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "paused", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + } + assert async_check_significant_change(None, "on", attrs["old"], "on", attrs["new"]) + + # change of list of possible activities is not significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing"], + }, + } + assert not async_check_significant_change( + None, "on", attrs["old"], "on", attrs["new"] + ) + + # change of any not official attribute is not significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + "not_official": "changed", + }, + } + assert not async_check_significant_change( + None, "on", attrs["old"], "on", attrs["new"] + ) diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 58d51eca537155..7f5cb9a81846ad 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -203,13 +203,12 @@ async def test_service_set_charge_schedule_multi( { "id": 2, "activated": True, - "monday": {"startTime": "T12:00Z", "duration": 15}, - "tuesday": {"startTime": "T12:00Z", "duration": 15}, - "wednesday": {"startTime": "T12:00Z", "duration": 15}, - "thursday": {"startTime": "T12:00Z", "duration": 15}, - "friday": {"startTime": "T12:00Z", "duration": 15}, - "saturday": {"startTime": "T12:00Z", "duration": 15}, - "sunday": {"startTime": "T12:00Z", "duration": 15}, + "monday": {"startTime": "T12:00Z", "duration": 30}, + "tuesday": {"startTime": "T12:00Z", "duration": 30}, + "wednesday": None, + "friday": {"startTime": "T12:00Z", "duration": 30}, + "saturday": {"startTime": "T12:00Z", "duration": 30}, + "sunday": {"startTime": "T12:00Z", "duration": 30}, }, {"id": 3}, ] @@ -238,6 +237,15 @@ async def test_service_set_charge_schedule_multi( mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] assert mock_action.mock_calls[0][1] == (mock_call_data,) + # Monday updated with new values + assert mock_call_data[1].monday.startTime == "T12:00Z" + assert mock_call_data[1].monday.duration == 30 + # Wednesday has original values cleared + assert mock_call_data[1].wednesday is None + # Thursday keeps original values + assert mock_call_data[1].thursday.startTime == "T23:30Z" + assert mock_call_data[1].thursday.duration == 15 + async def test_service_invalid_device_id( hass: HomeAssistant, config_entry: ConfigEntry diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 3efc1e481df5e1..3f81a30f898e33 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -6,7 +6,13 @@ from homeassistant.components.reolink import const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac @@ -19,9 +25,14 @@ 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 +TEST_HOST_MODEL = "RLN8-410" +TEST_CAM_MODEL = "RLC-123" @pytest.fixture @@ -51,6 +62,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 +71,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.model = TEST_HOST_MODEL + host_mock.camera_model.return_value = TEST_CAM_MODEL + 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 @@ -99,7 +127,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..9f70673695ca72 --- /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': 'RLN8-410', + 'stream channels': list([ + 0, + ]), + 'stream protocol': 'rtsp', + }) +# --- diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 9b449d4b851754..dd9949a5dce574 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -14,7 +14,13 @@ from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from homeassistant.util.dt import utcnow @@ -68,7 +74,7 @@ async def test_config_flow_manual_success( const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -195,7 +201,7 @@ async def test_config_flow_errors( const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -212,7 +218,7 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: "rtsp", + CONF_PROTOCOL: "rtsp", }, title=TEST_NVR_NAME, ) @@ -228,12 +234,12 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={const.CONF_PROTOCOL: "rtmp"}, + user_input={CONF_PROTOCOL: "rtmp"}, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == { - const.CONF_PROTOCOL: "rtmp", + CONF_PROTOCOL: "rtmp", } @@ -252,7 +258,7 @@ async def test_change_connection_settings( const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) @@ -295,7 +301,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) @@ -376,7 +382,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -435,7 +441,7 @@ async def test_dhcp_ip_update( const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py new file mode 100644 index 00000000000000..57b474c13ad9d1 --- /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_init.py b/tests/components/reolink/test_init.py index e2bd622bb43a3b..654901294869f5 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -11,11 +11,15 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_MAC, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -102,6 +106,7 @@ async def test_entry_reloading( reolink_connect: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" + reolink_connect.is_nvr = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -115,6 +120,94 @@ async def test_entry_reloading( assert config_entry.title == "New Name" +@pytest.mark.parametrize( + ("attr", "value", "expected_models"), + [ + ( + None, + None, + [TEST_HOST_MODEL, TEST_CAM_MODEL], + ), + ("channels", [], [TEST_HOST_MODEL]), + ( + "camera_model", + Mock(return_value="RLC-567"), + [TEST_HOST_MODEL, "RLC-567"], + ), + ], +) +async def test_cleanup_disconnected_cams( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + attr: str | None, + value: Any, + expected_models: list[str], +) -> None: + """Test device and entity registry are cleaned up when camera is disconnected from NVR.""" + reolink_connect.channels = [0] + # setup CH 0 and NVR switch entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted([TEST_HOST_MODEL, TEST_CAM_MODEL]) + + # reload integration after 'disconnecting' a camera. + if attr is not None: + setattr(reolink_connect, attr, value) + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_reload(config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted(expected_models) + + +async def test_cleanup_deprecated_entities( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test deprecated ir_lights light entity is cleaned.""" + reolink_connect.channels = [0] + ir_id = f"{TEST_MAC}_0_ir_lights" + + entity_registry.async_get_or_create( + domain=Platform.LIGHT, + platform=const.DOMAIN, + unique_id=ir_id, + config_entry=config_entry, + suggested_object_id=ir_id, + disabled_by=None, + ) + + assert entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id) + assert ( + entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id) + is None + ) + + # setup CH 0 and NVR switch entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id) is None + ) + assert entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id) + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py new file mode 100644 index 00000000000000..ddb664634194f8 --- /dev/null +++ b/tests/components/reolink/test_media_source.py @@ -0,0 +1,289 @@ +"""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_PROTOCOL, + 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={ + 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/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 6c9b51a7cf67d1..1f68c9a28d3dc2 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -338,6 +338,7 @@ async def test_fix_issue( "description_placeholders": None, "flow_id": flow_id, "handler": domain, + "minor_version": 1, "type": "create_entry", "version": 1, } diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index d57cd41aa10b35..7be2ce4c63ebf7 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -1,5 +1,4 @@ """The tests for the REST switch platform.""" -import asyncio from http import HTTPStatus import httpx @@ -53,6 +52,22 @@ STATE_RESOURCE = RESOURCE +@pytest.fixture( + params=( + HTTPStatus.OK, + HTTPStatus.CREATED, + HTTPStatus.ACCEPTED, + HTTPStatus.NON_AUTHORITATIVE_INFORMATION, + HTTPStatus.NO_CONTENT, + HTTPStatus.RESET_CONTENT, + HTTPStatus.PARTIAL_CONTENT, + ) +) +def http_success_code(request: pytest.FixtureRequest) -> HTTPStatus: + """Fixture providing different successful HTTP response code.""" + return request.param + + async def test_setup_missing_config( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -61,7 +76,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' from integration 'rest': required key 'resource' " + "not provided" in caplog.text + ) async def test_setup_missing_schema( @@ -72,7 +90,10 @@ 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' from integration 'rest': invalid url" + in caplog.text + ) @respx.mock @@ -81,7 +102,7 @@ async def test_setup_failed_connect( caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when connection error occurs.""" - respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=httpx.ConnectError("")) config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() @@ -95,7 +116,7 @@ async def test_setup_timeout( caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when connection timeout occurs.""" - respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=httpx.TimeoutException("")) config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() @@ -256,11 +277,14 @@ async def test_is_on_before_update(hass: HomeAssistant) -> None: @respx.mock -async def test_turn_on_success(hass: HomeAssistant) -> None: +async def test_turn_on_success( + hass: HomeAssistant, + http_success_code: HTTPStatus, +) -> None: """Test turn_on.""" await _async_setup_test_switch(hass) - route = respx.post(RESOURCE) % HTTPStatus.OK + route = respx.post(RESOURCE) % http_success_code respx.get(RESOURCE).mock(side_effect=httpx.RequestError) await hass.services.async_call( SWITCH_DOMAIN, @@ -301,7 +325,7 @@ async def test_turn_on_timeout(hass: HomeAssistant) -> None: """Test turn_on when timeout occurs.""" await _async_setup_test_switch(hass) - respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR + respx.post(RESOURCE).mock(side_effect=httpx.TimeoutException("")) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -314,11 +338,14 @@ async def test_turn_on_timeout(hass: HomeAssistant) -> None: @respx.mock -async def test_turn_off_success(hass: HomeAssistant) -> None: +async def test_turn_off_success( + hass: HomeAssistant, + http_success_code: HTTPStatus, +) -> None: """Test turn_off.""" await _async_setup_test_switch(hass) - route = respx.post(RESOURCE) % HTTPStatus.OK + route = respx.post(RESOURCE) % http_success_code respx.get(RESOURCE).mock(side_effect=httpx.RequestError) await hass.services.async_call( SWITCH_DOMAIN, @@ -361,7 +388,7 @@ async def test_turn_off_timeout(hass: HomeAssistant) -> None: """Test turn_off when timeout occurs.""" await _async_setup_test_switch(hass) - respx.post(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.post(RESOURCE).mock(side_effect=httpx.TimeoutException("")) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -414,7 +441,7 @@ async def test_update_timeout(hass: HomeAssistant) -> None: """Test update when timeout occurs.""" await _async_setup_test_switch(hass) - respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=httpx.TimeoutException("")) async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 4562bf928c8eb0..e5a5c73de39a41 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -216,7 +216,7 @@ async def test_setup_network_fail(transport_mock, hass: HomeAssistant) -> None: @patch("serial.tools.list_ports.comports", return_value=[com_port()]) @patch( "homeassistant.components.rfxtrx.rfxtrxmod.PySerialTransport.connect", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ) async def test_setup_serial_fail(com_mock, connect_mock, hass: HomeAssistant) -> None: """Test setup serial failed connection.""" diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr index a98374d2941c36..d32b1d3f446089 100644 --- a/tests/components/ridwell/snapshots/test_diagnostics.ambr +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -36,6 +36,7 @@ 'disabled_by': None, 'domain': 'ridwell', 'entry_id': '11554ec901379b9cc8f5a6c1d11ce978', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/ring/conftest.py b/tests/components/ring/conftest.py index 2b6edf86132ca3..e9800393835951 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 00000000000000..64e753ba2b3224 --- /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 3e0c354e8fa35f..53c7e139a51349 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 00000000000000..269446c3ad5dd7 --- /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 7e3f5344f73e01..6ad79623a1220a 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 cb3b3dd929e761..a8a764cd50279f 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 5a9b60ed13010b..8207ad819b7858 100644 --- a/tests/components/risco/test_config_flow.py +++ b/tests/components/risco/test_config_flow.py @@ -9,7 +9,7 @@ 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 00000000000000..a1a9e3bd6a7ac8 --- /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/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index 3153005d094c5e..a055e8fed05a02 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -15,6 +15,7 @@ EntityCategory, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -84,7 +85,7 @@ async def test_select_invalid_option(hass: HomeAssistant) -> None: assert state assert state.state == "60" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 3435bd58cb39f6..711ae203e0f2c9 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -12,7 +12,16 @@ 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", ), patch( - "roborock.api.AttributeCache.value" + "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 87ed02bc3ecc0a..8935a77f142a5e 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 @@ 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/test_config_flow.py b/tests/components/roborock/test_config_flow.py index bbaa8935461fe9..e2454b3ad57fc7 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 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 00000000000000..80d4bd373372fe --- /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 a5ad24b431c548..5d1afaf8f844ea 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 35fcc9478cdd92..4966c8fa3be501 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/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 5d4568ce7ac929..c186741aac93b9 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError @@ -153,6 +154,7 @@ async def test_availability( hass: HomeAssistant, mock_roku: MagicMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, error: RokuError, ) -> None: """Test entity availability.""" @@ -160,23 +162,22 @@ async def test_availability( future = now + timedelta(minutes=1) mock_config_entry.add_to_hass(hass) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + freezer.move_to(now) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = error - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE + freezer.move_to(future) + mock_roku.update.side_effect = error + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE future += timedelta(minutes=1) - - with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = None - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get(MAIN_ENTITY_ID).state == STATE_IDLE + freezer.move_to(future) + mock_roku.update.side_effect = None + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_IDLE async def test_supported_features( diff --git a/tests/components/rtsp_to_webrtc/conftest.py b/tests/components/rtsp_to_webrtc/conftest.py index f6ee0d1a628212..edb8c7c4aca7b0 100644 --- a/tests/components/rtsp_to_webrtc/conftest.py +++ b/tests/components/rtsp_to_webrtc/conftest.py @@ -51,9 +51,6 @@ async def mock_camera(hass) -> AsyncGenerator[None, None]: ), patch( "homeassistant.components.camera.Camera.stream_source", return_value=STREAM_SOURCE, - ), patch( - "homeassistant.components.camera.Camera.supported_features", - return_value=camera.SUPPORT_STREAM, ): yield diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 5e8ab9311aa3fe..6754faf2da6e18 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 @@ -233,7 +233,7 @@ def remotews_fixture() -> Mock: remotews.app_list_data = None async def _start_listening( - ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None + ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None, ): remotews.ws_event_callback = ws_event_callback @@ -272,7 +272,7 @@ def remoteencws_fixture() -> Mock: remoteencws.__aexit__ = AsyncMock() def _start_listening( - ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None + ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None, ): remoteencws.ws_event_callback = ws_event_callback diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index 231880a009b9cb..651b6f27a44c8b 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -41,6 +41,7 @@ async def test_entry_diagnostics( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", + "minor_version": 1, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, @@ -77,6 +78,7 @@ async def test_entry_diagnostics_encrypted( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", + "minor_version": 1, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, @@ -112,6 +114,7 @@ async def test_entry_diagnostics_encrypte_offline( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", + "minor_version": 1, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 674dea752a0644..27a06ef3a13820 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -11,6 +11,7 @@ UpnpError, UpnpResponseError, ) +from freezegun.api import FrozenDateTimeFactory import pytest from samsungctl import exceptions from samsungtvws.async_remote import SamsungTVWSAsyncRemote @@ -165,7 +166,9 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") -async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_setup_websocket_2( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Test setup of platform from config entry.""" entity_id = f"{DOMAIN}.fake" @@ -194,9 +197,9 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non assert config_entries[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state @@ -205,7 +208,7 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non @pytest.mark.usefixtures("rest_api") async def test_setup_encrypted_websocket( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Test setup of platform from config entry.""" with patch( @@ -219,9 +222,9 @@ async def test_setup_encrypted_websocket( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state @@ -229,21 +232,25 @@ async def test_setup_encrypted_websocket( @pytest.mark.usefixtures("remote") -async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_on( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv on.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @pytest.mark.usefixtures("remote") -async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_off( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -252,16 +259,20 @@ async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: side_effect=[OSError("Boom"), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE async def test_update_off_ws_no_power_state( - hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -276,9 +287,9 @@ async def test_update_off_ws_no_power_state( remotews.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -287,7 +298,11 @@ async def test_update_off_ws_no_power_state( @pytest.mark.usefixtures("remotews") async def test_update_off_ws_with_power_state( - hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" with patch.object( @@ -308,9 +323,9 @@ async def test_update_off_ws_with_power_state( device_info["device"]["PowerState"] = "on" rest_api.rest_device_info.return_value = device_info next_update = mock_now + timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() remotews.start_listening.assert_called_once() rest_api.rest_device_info.assert_called_once() @@ -324,9 +339,9 @@ async def test_update_off_ws_with_power_state( # Second update uses device_info(ON) rest_api.rest_device_info.reset_mock() next_update = mock_now + timedelta(minutes=2) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() rest_api.rest_device_info.assert_called_once() @@ -337,9 +352,9 @@ async def test_update_off_ws_with_power_state( rest_api.rest_device_info.reset_mock() device_info["device"]["PowerState"] = "off" next_update = mock_now + timedelta(minutes=3) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() rest_api.rest_device_info.assert_called_once() @@ -350,7 +365,11 @@ async def test_update_off_ws_with_power_state( async def test_update_off_encryptedws( - hass: HomeAssistant, remoteencws: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remoteencws: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) @@ -364,9 +383,9 @@ async def test_update_off_encryptedws( remoteencws.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -374,7 +393,9 @@ async def test_update_off_encryptedws( @pytest.mark.usefixtures("remote") -async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_access_denied( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv access denied exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -383,13 +404,14 @@ async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> side_effect=exceptions.AccessDenied("Boom"), ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() assert [ flow @@ -403,6 +425,7 @@ async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_failure( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_now: datetime, remotews: Mock, caplog: pytest.LogCaptureFixture, @@ -416,8 +439,8 @@ async def test_update_ws_connection_failure( side_effect=ConnectionFailure('{"event": "ms.voiceApp.hide"}'), ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert ( @@ -432,7 +455,10 @@ async def test_update_ws_connection_failure( @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_closed( - hass: HomeAssistant, mock_now: datetime, remotews: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_now: datetime, + remotews: Mock, ) -> None: """Testing update tv connection failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -441,8 +467,8 @@ async def test_update_ws_connection_closed( remotews, "start_listening", side_effect=ConnectionClosedError(None, None) ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -451,7 +477,10 @@ async def test_update_ws_connection_closed( @pytest.mark.usefixtures("rest_api") async def test_update_ws_unauthorized_error( - hass: HomeAssistant, mock_now: datetime, remotews: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_now: datetime, + remotews: Mock, ) -> None: """Testing update tv unauthorized failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -460,8 +489,8 @@ async def test_update_ws_unauthorized_error( remotews, "start_listening", side_effect=UnauthorizedError ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert [ @@ -475,7 +504,7 @@ async def test_update_ws_unauthorized_error( @pytest.mark.usefixtures("remote") async def test_update_unhandled_response( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Testing update tv unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -485,9 +514,9 @@ async def test_update_unhandled_response( side_effect=[exceptions.UnhandledResponse("Boom"), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -495,7 +524,7 @@ async def test_update_unhandled_response( @pytest.mark.usefixtures("remote") async def test_connection_closed_during_update_can_recover( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Testing update tv connection closed exception can recover.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -505,17 +534,17 @@ async def test_connection_closed_during_update_can_recover( side_effect=[exceptions.ConnectionClosed(), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -653,7 +682,7 @@ async def test_name(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("remote") -async def test_state(hass: HomeAssistant) -> None: +async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test for state property.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) await hass.services.async_call( @@ -672,7 +701,8 @@ async def test_state(hass: HomeAssistant) -> None: with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError, - ), patch("homeassistant.util.dt.utcnow", return_value=next_update): + ): + freezer.move_to(next_update) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() @@ -1393,7 +1423,11 @@ async def test_upnp_subscribe_events_upnpresponseerror( @pytest.mark.usefixtures("rest_api", "upnp_notify_server") async def test_upnp_re_subscribe_events( - hass: HomeAssistant, remotews: Mock, dmr_device: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + dmr_device: Mock, + mock_now: datetime, ) -> None: """Test for Upnp event feedback.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1407,9 +1441,9 @@ async def test_upnp_re_subscribe_events( remotews, "start_listening", side_effect=WebSocketException("Boom") ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -1417,9 +1451,9 @@ async def test_upnp_re_subscribe_events( assert dmr_device.async_unsubscribe_services.call_count == 1 next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -1434,6 +1468,7 @@ async def test_upnp_re_subscribe_events( ) async def test_upnp_failed_re_subscribe_events( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, remotews: Mock, dmr_device: Mock, mock_now: datetime, @@ -1452,9 +1487,9 @@ async def test_upnp_failed_re_subscribe_events( remotews, "start_listening", side_effect=WebSocketException("Boom") ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -1462,9 +1497,8 @@ async def test_upnp_failed_re_subscribe_events( assert dmr_device.async_unsubscribe_services.call_count == 1 next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch.object( - dmr_device, "async_subscribe_services", side_effect=error - ): + with patch.object(dmr_device, "async_subscribe_services", side_effect=error): + freezer.move_to(next_update) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 27f6d7a8e51ea5..12af639b251b11 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -57,7 +57,7 @@ async def test_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index 7b610a6b4da850..5f9676b7d09f4c 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 b256e8950ed18e..14121f5d9ca973 100644 --- a/tests/components/schlage/test_config_flow.py +++ b/tests/components/schlage/test_config_flow.py @@ -9,6 +9,8 @@ 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_init.py b/tests/components/schlage/test_init.py index 0811d87ec80114..0fe7af1982b5df 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/screenlogic/fixtures/data_full_chem.json b/tests/components/screenlogic/fixtures/data_full_chem.json index 6c9ece22fcfc11..8cef1e7d769e0d 100644 --- a/tests/components/screenlogic/fixtures/data_full_chem.json +++ b/tests/components/screenlogic/fixtures/data_full_chem.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { @@ -152,6 +154,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -681,32 +691,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 7.6, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 720, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 800, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 1000, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 32 @@ -814,7 +836,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "1.060" + "value": "1.060", + "major": 1, + "minor": 60 }, "water_balance": { "flags": 0, @@ -875,6 +899,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/fixtures/data_full_no_gpm.json b/tests/components/screenlogic/fixtures/data_full_no_gpm.json index 93e3040f911314..521d77cdb5cdce 100644 --- a/tests/components/screenlogic/fixtures/data_full_no_gpm.json +++ b/tests/components/screenlogic/fixtures/data_full_no_gpm.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 738.0 Rel" + "value": "POOL: 5.2 Build 738.0 Rel", + "major": 5.2, + "minor": 738.0 } }, "controller": { @@ -146,6 +148,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -585,32 +595,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 0.0, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 0, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 0 @@ -718,7 +740,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "0.000" + "value": "0.000", + "major": 0, + "minor": 0 }, "water_balance": { "flags": 0, @@ -779,6 +803,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json index d17d0e41170bdc..c37f20f35ab173 100644 --- a/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json +++ b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { @@ -146,6 +148,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -675,32 +685,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 7.6, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 720, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 800, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 1000, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 32 @@ -808,7 +830,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "1.060" + "value": "1.060", + "major": 1, + "minor": 60 }, "water_balance": { "flags": 0, @@ -854,6 +878,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json index 40f7dbe4ad50b5..25a5207401161b 100644 --- a/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json +++ b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { diff --git a/tests/components/screenlogic/fixtures/data_min_migration.json b/tests/components/screenlogic/fixtures/data_min_migration.json index 335c98db0ae7bb..6796eb301c4fbb 100644 --- a/tests/components/screenlogic/fixtures/data_min_migration.json +++ b/tests/components/screenlogic/fixtures/data_min_migration.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { diff --git a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json index c30ee690f8a925..aa0df6e3df6762 100644 --- a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json +++ b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { @@ -142,6 +144,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -659,32 +669,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 7.6, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 720, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 800, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 1000, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 32 @@ -792,7 +814,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "1.060" + "value": "1.060", + "major": 1, + "minor": 60 }, "water_balance": { "flags": 0, @@ -844,6 +868,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index 05320c147e5a79..9f1cc421a99058 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'screenlogic', 'entry_id': 'screenlogictest', + 'minor_version': 1, 'options': dict({ 'scan_interval': 30, }), @@ -22,6 +23,8 @@ 'data': dict({ 'adapter': dict({ 'firmware': dict({ + 'major': 5.2, + 'minor': 736.0, 'name': 'Protocol Adapter Firmware', 'value': 'POOL: 5.2 Build 736.0 Rel', }), @@ -453,6 +456,14 @@ 'unknown_at_offset_11': 0, }), 'controller_id': 100, + 'date_time': dict({ + 'auto_dst': dict({ + 'name': 'Automatic Daylight Saving Time', + 'value': 1, + }), + 'timestamp': 1700489169.0, + 'timestamp_host': 1700517812.0, + }), 'equipment': dict({ 'flags': 98360, 'list': list([ @@ -604,33 +615,45 @@ }), 'configuration': dict({ 'calcium_harness': dict({ + 'max_setpoint': 800, + 'min_setpoint': 25, 'name': 'Calcium Hardness', 'unit': 'ppm', 'value': 800, }), 'cya': dict({ + 'max_setpoint': 201, + 'min_setpoint': 0, 'name': 'Cyanuric Acid', 'unit': 'ppm', 'value': 45, }), 'flags': 32, 'orp_setpoint': dict({ + 'max_setpoint': 800, + 'min_setpoint': 400, 'name': 'ORP Setpoint', 'unit': 'mV', 'value': 720, }), 'ph_setpoint': dict({ + 'max_setpoint': 7.6, + 'min_setpoint': 7.2, 'name': 'pH Setpoint', 'unit': 'pH', 'value': 7.6, }), 'probe_is_celsius': 0, 'salt_tds_ppm': dict({ + 'max_setpoint': 6500, + 'min_setpoint': 500, 'name': 'Salt/TDS', 'unit': 'ppm', 'value': 1000, }), 'total_alkalinity': dict({ + 'max_setpoint': 800, + 'min_setpoint': 25, 'name': 'Total Alkalinity', 'unit': 'ppm', 'value': 45, @@ -688,6 +711,8 @@ }), }), 'firmware': dict({ + 'major': 1, + 'minor': 60, 'name': 'IntelliChem Firmware', 'value': '1.060', }), @@ -952,6 +977,10 @@ 'value': 0, }), }), + 'super_chlorinate': dict({ + 'name': 'Super Chlorinate', + 'value': 0, + }), }), }), 'debug': dict({ diff --git a/tests/components/select/test_init.py b/tests/components/select/test_init.py index 585972a0953209..604bf3f0fb9d7e 100644 --- a/tests/components/select/test_init.py +++ b/tests/components/select/test_init.py @@ -17,6 +17,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component @@ -111,8 +112,8 @@ async def test_custom_integration_and_validation( await hass.async_block_till_done() assert hass.states.get("select.select_1").state == "option 2" - # test ValueError trigger - with pytest.raises(ValueError): + # test ServiceValidationError trigger + with pytest.raises(ServiceValidationError) as exc: await hass.services.async_call( DOMAIN, SERVICE_SELECT_OPTION, @@ -120,11 +121,14 @@ async def test_custom_integration_and_validation( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == "not_valid_option" + assert hass.states.get("select.select_1").state == "option 2" assert hass.states.get("select.select_2").state == STATE_UNKNOWN - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/sensibo/test_button.py b/tests/components/sensibo/test_button.py index da6a68af2d1ceb..2277c84d187e08 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 530034720f2037..bf0113cb22bca6 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -55,7 +55,7 @@ STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -438,7 +438,7 @@ async def test_climate_temperature_is_none( with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), pytest.raises(ValueError): + ), pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -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, @@ -1330,10 +1330,7 @@ async def test_climate_fan_mode_and_swing_mode_not_supported( with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), pytest.raises( - HomeAssistantError, - match="Climate swing mode faulty_swing_mode is not supported by the integration, please open an issue", - ): + ), pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, @@ -1343,10 +1340,7 @@ async def test_climate_fan_mode_and_swing_mode_not_supported( with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), pytest.raises( - HomeAssistantError, - match="Climate fan mode faulty_fan_mode is not supported by the integration, please open an issue", - ): + ), pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index 7d8e373141591d..41a67dfbe7908f 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 c6d47ceed666f1..e319be85c7375d 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/sensor/test_init.py b/tests/components/sensor/test_init.py index fc714a543bf668..829bb5af8279e9 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -4,10 +4,12 @@ from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal +from types import ModuleType from typing import Any import pytest +from homeassistant.components import sensor from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import ( DEVICE_CLASS_STATE_CLASSES, @@ -50,6 +52,7 @@ MockModule, MockPlatform, async_mock_restore_state_shutdown_restart, + import_and_test_deprecated_constant_enum, mock_config_flow, mock_integration, mock_platform, @@ -2424,7 +2427,7 @@ async def async_setup_entry_platform( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test sensor platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) mock_platform( @@ -2519,3 +2522,59 @@ async def test_entity_category_config_raises_error( ) assert not hass.states.get("sensor.test") + + +@pytest.mark.parametrize(("enum"), list(sensor.SensorStateClass)) +@pytest.mark.parametrize(("module"), [sensor, sensor.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: sensor.SensorStateClass, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, "STATE_CLASS_", "2025.1" + ) + + +@pytest.mark.parametrize( + ("enum"), + [ + sensor.SensorDeviceClass.AQI, + sensor.SensorDeviceClass.BATTERY, + sensor.SensorDeviceClass.CO, + sensor.SensorDeviceClass.CO2, + sensor.SensorDeviceClass.CURRENT, + sensor.SensorDeviceClass.DATE, + sensor.SensorDeviceClass.ENERGY, + sensor.SensorDeviceClass.FREQUENCY, + sensor.SensorDeviceClass.GAS, + sensor.SensorDeviceClass.HUMIDITY, + sensor.SensorDeviceClass.ILLUMINANCE, + sensor.SensorDeviceClass.MONETARY, + sensor.SensorDeviceClass.NITROGEN_DIOXIDE, + sensor.SensorDeviceClass.NITROGEN_MONOXIDE, + sensor.SensorDeviceClass.NITROUS_OXIDE, + sensor.SensorDeviceClass.OZONE, + sensor.SensorDeviceClass.PM1, + sensor.SensorDeviceClass.PM10, + sensor.SensorDeviceClass.PM25, + sensor.SensorDeviceClass.POWER_FACTOR, + sensor.SensorDeviceClass.POWER, + sensor.SensorDeviceClass.PRESSURE, + sensor.SensorDeviceClass.SIGNAL_STRENGTH, + sensor.SensorDeviceClass.SULPHUR_DIOXIDE, + sensor.SensorDeviceClass.TEMPERATURE, + sensor.SensorDeviceClass.TIMESTAMP, + sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + sensor.SensorDeviceClass.VOLTAGE, + ], +) +def test_deprecated_constants_sensor_device_class( + caplog: pytest.LogCaptureFixture, + enum: sensor.SensorStateClass, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, sensor, enum, "DEVICE_CLASS_", "2025.1" + ) diff --git a/tests/components/sensorpush/test_sensor.py b/tests/components/sensorpush/test_sensor.py index e00b626b20b2b9..2e7a08673096db 100644 --- a/tests/components/sensorpush/test_sensor.py +++ b/tests/components/sensorpush/test_sensor.py @@ -1,7 +1,6 @@ """Test the SensorPush sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -22,6 +21,7 @@ from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -55,9 +55,8 @@ async def test_sensors(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 464118ac99b90f..0384e9255a38fc 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -7,6 +7,7 @@ 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 @@ 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/bluetooth/test_scanner.py b/tests/components/shelly/bluetooth/test_scanner.py index bd44782f928c69..9fe5f77f00c946 100644 --- a/tests/components/shelly/bluetooth/test_scanner.py +++ b/tests/components/shelly/bluetooth/test_scanner.py @@ -108,19 +108,6 @@ async def test_scanner_ignores_wrong_version_and_logs( assert "Unsupported BLE scan result version: 0" in caplog.text -async def test_scanner_minimum_firmware_log_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture -) -> None: - """Test scanner log error if device firmware incompatible.""" - monkeypatch.setattr(mock_rpc_device, "version", "0.11.0") - await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} - ) - assert mock_rpc_device.initialized is True - - assert "BLE not supported on device" in caplog.text - - async def test_scanner_warns_on_corrupt_event( hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 438ca9b5ace38d..8a863a852f524d 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -4,6 +4,7 @@ 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 @@ "device": { "mac": MOCK_MAC, "hostname": "test-host", - "type": "SHSW-25", + "type": MODEL_25, "num_outputs": 2, }, "coiot": {"update_period": 15}, @@ -148,17 +149,21 @@ def mock_light_set_state( "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"}, - "wakeup_period": 0, }, } MOCK_SHELLY_COAP = { "mac": MOCK_MAC, "auth": False, - "fw": "20201124-092854/v1.9.0@57ac4ad8", + "fw": "20210715-092854/v1.11.0@57ac4ad8", "num_outputs": 2, } @@ -166,14 +171,15 @@ def mock_light_set_state( "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", + "fw_id": "20230803-130540/1.0.0-gfa1bc37", + "ver": "1.0.0", "app": "Plus2PM", "auth_en": False, "auth_domain": None, "profile": "cover", + "relay_in_thermostat": True, } MOCK_STATUS_COAP = { @@ -207,6 +213,13 @@ def mock_light_set_state( "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"}, @@ -276,11 +289,12 @@ def update_reply(): blocks=MOCK_BLOCKS, settings=MOCK_SETTINGS, shelly=MOCK_SHELLY_COAP, - version="0.10.0", + version="1.11.0", 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 @@ -299,7 +313,7 @@ def _mock_rpc_device(version: str | None = None): config=MOCK_CONFIG, event={}, shelly=MOCK_SHELLY_RPC, - version=version or "0.12.0", + version=version or "1.0.0", hostname="test-host", status=MOCK_STATUS_RPC, firmware_version="some fw string", @@ -309,23 +323,6 @@ def _mock_rpc_device(version: str | None = None): return device -@pytest.fixture -async def mock_pre_ble_rpc_device(): - """Mock rpc (Gen2, Websocket) device pre BLE.""" - with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: - - def update(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.STATUS - ) - - device = _mock_rpc_device("0.11.0") - rpc_device_mock.return_value = device - rpc_device_mock.return_value.mock_update = Mock(side_effect=update) - - yield rpc_device_mock.return_value - - @pytest.fixture async def mock_rpc_device(): """Mock rpc (Gen2, Websocket) device with BLE support.""" @@ -348,7 +345,7 @@ def disconnected(): {}, RpcUpdateType.DISCONNECTED ) - device = _mock_rpc_device("0.12.0") + device = _mock_rpc_device() rpc_device_mock.return_value = device rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) rpc_device_mock.return_value.mock_update = Mock(side_effect=update) diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 8905ff5c3e8c98..8a5e0108ad71ef 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 08ec548d3f01fb..980981de75410b 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 @@ 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.exceptions import HomeAssistantError, ServiceValidationError +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() @@ -376,12 +382,13 @@ async def test_block_restored_climate_set_preset_before_online( assert hass.states.get(entity_id).state == HVACMode.HEAT - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: "Profile1"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: "Profile1"}, + blocking=True, + ) mock_block_device.http_request.assert_not_called() @@ -497,12 +504,13 @@ async def test_block_restored_climate_auth_error( async def test_device_not_calibrated( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, + mock_block_device, + monkeypatch, + issue_registry: ir.IssueRegistry, ) -> None: """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 +542,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 073847e03089ad..1bccd3570cf61a 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -5,6 +5,7 @@ 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,9 @@ @pytest.mark.parametrize( ("gen", "model"), [ - (1, "SHSW-1"), - (2, "SNSW-002P16EU"), + (1, MODEL_1), + (2, MODEL_PLUS_2PM), + (3, MODEL_PLUS_2PM), ], ) async def test_form( @@ -68,7 +70,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 +100,19 @@ 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", + ), + ( + 3, + MODEL_PLUS_2PM, {"password": "test2 password"}, "admin", ), @@ -128,7 +136,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 +314,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 +347,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 +464,18 @@ 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}, + ), + ( + 3, + MODEL_PLUS_2PM, + {"mac": "test-mac", "model": MODEL_PLUS_2PM, "auth": False, "gen": 3}, ), ], ) @@ -525,7 +538,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 +572,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 +586,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 +613,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 +640,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 +661,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 +713,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 +739,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", @@ -741,6 +754,7 @@ async def test_zeroconf_require_auth(hass: HomeAssistant, mock_block_device) -> [ (1, {"username": "test user", "password": "test1 password"}), (2, {"password": "test2 password"}), + (3, {"password": "test2 password"}), ], ) async def test_reauth_successful( @@ -754,7 +768,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, @@ -779,6 +793,7 @@ async def test_reauth_successful( [ (1, {"username": "test user", "password": "test1 password"}), (2, {"password": "test2 password"}), + (3, {"password": "test2 password"}), ], ) async def test_reauth_unsuccessful(hass: HomeAssistant, gen, user_input) -> None: @@ -790,7 +805,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), @@ -966,62 +981,6 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_options_flow_pre_ble_device( - hass: HomeAssistant, mock_pre_ble_rpc_device -) -> None: - """Test setting ble options for gen2 devices with pre ble firmware.""" - entry = await init_integration(hass, 2) - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "ble_unsupported" - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "ble_unsupported" - - await hass.config_entries.async_unload(entry.entry_id) - - async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( hass: HomeAssistant, mock_rpc_device, monkeypatch ) -> None: @@ -1029,7 +988,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 +997,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 +1020,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 +1029,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 +1052,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 +1064,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 +1107,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 8ce80b70032dd8..27aa8710621062 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() @@ -249,11 +250,12 @@ async def test_block_sleeping_device_no_periodic_updates( async def test_block_device_push_updates_failure( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, + mock_block_device, + monkeypatch, + issue_registry: ir.IssueRegistry, ) -> None: """Test block device with push updates failure.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) - await init_integration(hass, 1) # Updates with COAP_REPLAY type should create an issue @@ -284,7 +286,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 143501ef620e93..9a63e66980a75c 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 39f1ef8d723983..3a9b548757b473 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}}, @@ -129,14 +130,13 @@ async def test_rpc_config_entry_diagnostics( "scanning": True, "start_time": ANY, "source": "12:34:56:78:9A:BC", - "storage": None, "time_since_last_device_detection": {"AA:BB:CC:DD:EE:FF": ANY}, "type": "ShellyBLEScanner", } }, "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 b7824d8d7ac38b..09439adc6f7218 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_init.py b/tests/components/shelly/test_init.py index 2ead9cba198020..8f6599b39e46e9 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -41,7 +41,7 @@ async def test_custom_coap_port( assert "Starting CoAP context with UDP port 7632" in caplog.text -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_shared_device_mac( hass: HomeAssistant, gen, @@ -74,7 +74,7 @@ async def test_setup_entry_not_shelly( assert "probably comes from a custom integration" in caplog.text -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_device_connection_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch ) -> None: @@ -90,7 +90,7 @@ async def test_device_connection_error( assert entry.state == ConfigEntryState.SETUP_RETRY -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_mac_mismatch_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch ) -> None: @@ -106,7 +106,7 @@ async def test_mac_mismatch_error( assert entry.state == ConfigEntryState.SETUP_RETRY -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_device_auth_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch ) -> None: diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 69d0fccf421b06..77b65ad3bb589c 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 @@ 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") @@ -125,7 +134,10 @@ async def test_block_device_rgb_bulb( ColorMode.COLOR_TEMP, ColorMode.RGB, ] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert ( + attributes[ATTR_SUPPORTED_FEATURES] + == LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION + ) assert len(attributes[ATTR_EFFECT_LIST]) == 4 assert attributes[ATTR_EFFECT] == "Off" @@ -215,7 +227,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") @@ -223,7 +235,7 @@ async def test_block_device_white_bulb( assert state.state == STATE_ON assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -259,12 +271,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 115ad5edabb0af..9a99116e66cac2 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -1,10 +1,16 @@ """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 import automation, script +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.script import scripts_with_entity +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 ( @@ -18,8 +24,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component -from . import init_integration +from . import init_integration, register_entity RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 @@ -235,9 +243,14 @@ async def test_block_device_gas_valve( hass: HomeAssistant, mock_block_device, monkeypatch ) -> None: """Test block device Shelly Gas with Valve addon.""" + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_valve", + "valve_0-valve", + ) registry = er.async_get(hass) - await init_integration(hass, 1, "SHGS-1") - entity_id = "switch.test_name_valve" + await init_integration(hass, 1, MODEL_GAS) entry = registry.async_get(entity_id) assert entry @@ -277,3 +290,99 @@ 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 + + +async def test_create_issue_valve_switch( + hass: HomeAssistant, + mock_block_device, + entity_registry_enabled_by_default: None, + monkeypatch, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_valve", + "valve_0-valve", + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": {"service": "switch.turn_on", "entity_id": entity_id}, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "service": "switch.turn_on", + "data": {"entity_id": entity_id}, + }, + ], + } + } + }, + ) + + await init_integration(hass, 1, MODEL_GAS) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_valve_switch") + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_valve_switch.test_name_valve_automation.test" + ) + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_valve_switch.test_name_valve_script.test" + ) + + assert len(issue_registry.issues) == 3 diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 454afb73ce15bd..06eac49e29385d 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -5,11 +5,16 @@ 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 3d273ff3059227..e47f9e451b4332 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/shelly/test_valve.py b/tests/components/shelly/test_valve.py new file mode 100644 index 00000000000000..0db79b63a9d9b6 --- /dev/null +++ b/tests/components/shelly/test_valve.py @@ -0,0 +1,72 @@ +"""Tests for Shelly valve platform.""" +from aioshelly.const import MODEL_GAS + +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +GAS_VALVE_BLOCK_ID = 6 + + +async def test_block_device_gas_valve( + hass: HomeAssistant, mock_block_device, monkeypatch +) -> None: + """Test block device Shelly Gas with Valve addon.""" + registry = er.async_get(hass) + await init_integration(hass, 1, MODEL_GAS) + entity_id = "valve.test_name_valve" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-valve_0-valve" + + assert hass.states.get(entity_id).state == STATE_CLOSED + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPENING + + monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSING + + monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "closed") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 681ccea60ac83c..373c449497c7e6 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -7,45 +7,29 @@ from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError 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 +39,21 @@ async def get() -> list[dict[str, str]]: @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 +63,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 +73,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 +94,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 +121,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 +162,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 +206,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 +259,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,12 +335,11 @@ 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.""" - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( TODO_DOMAIN, "update_item", @@ -410,7 +381,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 +445,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 4b8686d7a7ffc7..1b9f9f02cee38b 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_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index 3153674ce57635..538165bd769f5f 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -17,6 +17,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": "simplisafe", "title": REDACTED, "data": {"token": REDACTED, "username": REDACTED}, diff --git a/tests/components/simplisafe/test_init.py b/tests/components/simplisafe/test_init.py index 617b77f7c98ad5..cc7b2b8d2b6dd3 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/siren/test_init.py b/tests/components/siren/test_init.py index 267b1c1e30d97f..abc5b0fac38b82 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -1,8 +1,10 @@ """The tests for the siren component.""" +from types import ModuleType from unittest.mock import MagicMock import pytest +from homeassistant.components import siren from homeassistant.components.siren import ( SirenEntity, SirenEntityDescription, @@ -11,6 +13,8 @@ from homeassistant.components.siren.const import SirenEntityFeature from homeassistant.core import HomeAssistant +from tests.common import import_and_test_deprecated_constant_enum + class MockSirenEntity(SirenEntity): """Mock siren device to use in tests.""" @@ -104,3 +108,31 @@ async def test_missing_tones_dict(hass: HomeAssistant) -> None: siren.hass = hass with pytest.raises(ValueError): process_turn_on_params(siren, {"tone": 3}) + + +@pytest.mark.parametrize(("enum"), list(SirenEntityFeature)) +@pytest.mark.parametrize(("module"), [siren, siren.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: SirenEntityFeature, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, module, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockSirenEntity(siren.SirenEntity): + _attr_supported_features = 1 + + entity = MockSirenEntity() + assert entity.supported_features is siren.SirenEntityFeature(1) + assert "MockSirenEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "SirenEntityFeature.TURN_ON" in caplog.text + caplog.clear() + assert entity.supported_features is siren.SirenEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/skybell/__init__.py b/tests/components/skybell/__init__.py index fc049adcc3dc32..ae9b6d132e41cd 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 4318fa8c24ff74..beb3fec9b98467 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 00000000000000..4ed5c027821c04 --- /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 00000000000000..3f8157c15c8611 --- /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 00000000000000..1276c2cfc0ffef --- /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 00000000000000..7b522aa687d597 --- /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 00000000000000..d858bb20e36df6 --- /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 00000000000000..46af5f0bd4b2ad --- /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 00000000000000..6e2c8dd199b637 --- /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 00000000000000..c7eaa44b5abc63 --- /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 00000000000000..ab6bfd7053ca40 --- /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 00000000000000..7b27c95ec01357 --- /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 00000000000000..e674df1c9c8340 --- /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 00000000000000..8e0bc884730079 --- /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 f93c1d6ae4f563..d83f4243d7fa77 100644 --- a/tests/components/skybell/test_config_flow.py +++ b/tests/components/skybell/test_config_flow.py @@ -11,7 +11,7 @@ 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/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index 05104546f0de8f..58718edcafb58c 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -6,9 +6,11 @@ from asyncsleepiq import ( BED_PRESETS, + FootWarmingTemps, Side, SleepIQActuator, SleepIQBed, + SleepIQFootWarmer, SleepIQFoundation, SleepIQLight, SleepIQPreset, @@ -34,6 +36,7 @@ SLEEPER_R_NAME_LOWER = SLEEPER_R_NAME.lower().replace(" ", "_") PRESET_L_STATE = "Watch TV" PRESET_R_STATE = "Flat" +FOOT_WARM_TIME = 120 SLEEPIQ_CONFIG = { CONF_USERNAME: "user@email.com", @@ -86,6 +89,7 @@ def mock_bed() -> MagicMock: light_2.is_on = False bed.foundation.lights = [light_1, light_2] + bed.foundation.foot_warmers = [] return bed @@ -120,6 +124,8 @@ def mock_asyncsleepiq_single_foundation( preset.side = Side.NONE preset.side_full = "Right" preset.options = BED_PRESETS + + mock_bed.foundation.foot_warmers = [] yield client @@ -166,6 +172,18 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock, None, None]: preset_r.side_full = "Right" preset_r.options = BED_PRESETS + foot_warmer_l = create_autospec(SleepIQFootWarmer) + foot_warmer_r = create_autospec(SleepIQFootWarmer) + mock_bed.foundation.foot_warmers = [foot_warmer_l, foot_warmer_r] + + foot_warmer_l.side = Side.LEFT + foot_warmer_l.timer = FOOT_WARM_TIME + foot_warmer_l.temperature = FootWarmingTemps.MEDIUM + + foot_warmer_r.side = Side.RIGHT + foot_warmer_r.timer = FOOT_WARM_TIME + foot_warmer_r.temperature = FootWarmingTemps.OFF + yield client diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index fe03a4d9c3f8f2..4676cf94174746 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -156,3 +156,41 @@ async def test_actuators(hass: HomeAssistant, mock_asyncsleepiq) -> None: mock_asyncsleepiq.beds[BED_ID].foundation.actuators[ 0 ].set_position.assert_called_with(42) + + +async def test_foot_warmer_timer(hass: HomeAssistant, mock_asyncsleepiq) -> None: + """Test the SleepIQ foot warmer number values for a bed with two sides.""" + entry = await setup_platform(hass, DOMAIN) + entity_registry = er.async_get(hass) + + state = hass.states.get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer" + ) + assert state.state == "120.0" + assert state.attributes.get(ATTR_ICON) == "mdi:timer" + assert state.attributes.get(ATTR_MIN) == 30 + assert state.attributes.get(ATTR_MAX) == 360 + assert state.attributes.get(ATTR_STEP) == 30 + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Foot Warming Timer" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_L_foot_warming_timer" + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer", + ATTR_VALUE: 300, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[0].timer == 300 diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index d0e2a0e828d115..c4ec3896bd743f 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -1,6 +1,8 @@ """Tests for the SleepIQ select platform.""" from unittest.mock import MagicMock +from asyncsleepiq import FootWarmingTemps + from homeassistant.components.select import DOMAIN, SERVICE_SELECT_OPTION from homeassistant.const import ( ATTR_ENTITY_ID, @@ -15,8 +17,15 @@ BED_ID, BED_NAME, BED_NAME_LOWER, + FOOT_WARM_TIME, PRESET_L_STATE, PRESET_R_STATE, + SLEEPER_L_ID, + SLEEPER_L_NAME, + SLEEPER_L_NAME_LOWER, + SLEEPER_R_ID, + SLEEPER_R_NAME, + SLEEPER_R_NAME_LOWER, setup_platform, ) @@ -115,3 +124,74 @@ async def test_single_foundation_preset( mock_asyncsleepiq_single_foundation.beds[BED_ID].foundation.presets[ 0 ].set_preset.assert_called_with("Zero G") + + +async def test_foot_warmer(hass: HomeAssistant, mock_asyncsleepiq: MagicMock) -> None: + """Test the SleepIQ select entity for foot warmers.""" + entry = await setup_platform(hass, DOMAIN) + entity_registry = er.async_get(hass) + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer" + ) + assert state.state == FootWarmingTemps.MEDIUM.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Foot Warmer" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_L_ID}_foot_warmer" + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer", + ATTR_OPTION: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ + 0 + ].turn_off.assert_called_once() + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer" + ) + assert state.state == FootWarmingTemps.OFF.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Foot Warmer" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_R_ID}_foot_warmer" + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer", + ATTR_OPTION: "high", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ + 1 + ].turn_on.assert_called_once() + mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ + 1 + ].turn_on.assert_called_with(FootWarmingTemps.HIGH, FOOT_WARM_TIME) diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index f6f5ab66708040..8d4d7b8c3b27f4 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 ce875190efb146..e74d69f04c991e 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -15,16 +15,20 @@ 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/smarttub/test_climate.py b/tests/components/smarttub/test_climate.py index 601015ca681760..40e3c05b509d9d 100644 --- a/tests/components/smarttub/test_climate.py +++ b/tests/components/smarttub/test_climate.py @@ -58,7 +58,7 @@ async def test_thermostat_update( assert state.attributes[ATTR_TEMPERATURE] == 39 assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP - assert state.attributes[ATTR_PRESET_MODES] == ["none", "eco", "day"] + assert state.attributes[ATTR_PRESET_MODES] == ["none", "eco", "day", "ready"] await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index ade151ed1283d5..eb7378b5cba2d6 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, @@ -257,7 +669,6 @@ # --- # name: test_setup_hass ReadOnlyDict({ - 'apparent_temperature': 18.0, 'attribution': 'Swedish weather institute (SMHI)', 'cloud_coverage': 100, 'forecast': list([ diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 67aa18ea75d392..f12aa92df3c77e 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -20,7 +20,8 @@ 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 86a21c754ed3f0..182b45d9c1b9f7 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.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 @@ def message(): ( "Test msg", {"images": ["tests/testing_config/notify/test.jpg"]}, - "Content-Type: multipart/related", + "Content-Type: multipart/mixed", ), ( "Test msg", @@ -110,7 +112,7 @@ def message(): ), ( "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 @@ def message(): ], ) 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/snmp/conftest.py b/tests/components/snmp/conftest.py deleted file mode 100644 index 05a518ad7f3bb2..00000000000000 --- a/tests/components/snmp/conftest.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Skip test collection for Python 3.12.""" -import sys - -if sys.version_info >= (3, 12): - collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 550040a9b257a6..de9aab016eef5e 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -2,6 +2,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.solaredge.const import ( CONF_SITE_ID, @@ -9,7 +10,6 @@ DOMAIN, OVERVIEW_UPDATE_DELAY, ) -from homeassistant.components.solaredge.sensor import SENSOR_TYPES from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -19,6 +19,11 @@ API_KEY = "a1b2c3d4e5f6g7h8" +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default): + """Make sure all entities are enabled.""" + + @patch("homeassistant.components.solaredge.Solaredge") async def test_solaredgeoverviewdataservice_energy_values_validity( mock_solaredge, hass: HomeAssistant, freezer: FrozenDateTimeFactory @@ -31,8 +36,6 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( ) mock_solaredge().get_details.return_value = {"details": {"status": "active"}} mock_config_entry.add_to_hass(hass) - for description in SENSOR_TYPES: - description.entity_registry_enabled_default = True await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 9f27e59365785f..e44081f94bf767 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -1,8 +1,9 @@ """Tests for the Sonarr sensor platform.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from aiopyarr import ArrException +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -122,6 +123,7 @@ async def test_disabled_by_default_sensors( async def test_availability( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, mock_sonarr: MagicMock, ) -> None: @@ -129,9 +131,9 @@ async def test_availability( now = dt_util.utcnow() mock_config_entry.add_to_hass(hass) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + freezer.move_to(now) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" @@ -140,9 +142,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = ArrException future = now + timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE @@ -151,9 +153,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = None future += timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" @@ -162,9 +164,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = ArrException future += timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE @@ -173,9 +175,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = None future += timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index cb912af1cf6799..8bd8224e726057 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -108,8 +108,26 @@ def config_entry_fixture(): class MockSoCo(MagicMock): """Mock the Soco Object.""" + uid = "RINCON_test" + play_mode = "NORMAL" + mute = False + night_mode = True + dialog_level = True + loudness = True + volume = 19 audio_delay = 2 + balance = (61, 100) + bass = 1 + treble = -1 + mic_enabled = False + sub_crossover = None # Default to None for non-Amp devices + sub_enabled = False sub_gain = 5 + surround_enabled = True + surround_mode = True + surround_level = 3 + music_surround_level = 4 + soundbar_audio_input_format = "Dolby 5.1" @property def visible_zones(self): @@ -143,10 +161,7 @@ def cache_mock( mock_soco.mock_add_spec(SoCo) mock_soco.ip_address = ip_address if ip_address != "192.168.42.2": - mock_soco.uid = f"RINCON_test_{ip_address}" - else: - mock_soco.uid = "RINCON_test" - mock_soco.play_mode = "NORMAL" + mock_soco.uid += f"_{ip_address}" mock_soco.music_library = self.music_library mock_soco.get_current_track_info.return_value = self.current_track_info mock_soco.music_source_from_uri = SoCo.music_source_from_uri @@ -161,23 +176,6 @@ def cache_mock( mock_soco.contentDirectory = SonosMockService("ContentDirectory", ip_address) mock_soco.deviceProperties = SonosMockService("DeviceProperties", ip_address) mock_soco.alarmClock = self.alarm_clock - mock_soco.mute = False - mock_soco.night_mode = True - mock_soco.dialog_level = True - mock_soco.loudness = True - mock_soco.volume = 19 - mock_soco.audio_delay = 2 - mock_soco.balance = (61, 100) - mock_soco.bass = 1 - mock_soco.treble = -1 - mock_soco.mic_enabled = False - mock_soco.sub_enabled = False - mock_soco.sub_gain = 5 - mock_soco.surround_enabled = True - mock_soco.surround_mode = True - mock_soco.surround_level = 3 - mock_soco.music_surround_level = 4 - mock_soco.soundbar_audio_input_format = "Dolby 5.1" mock_soco.get_battery_info.return_value = self.battery_info mock_soco.all_zones = {mock_soco} mock_soco.group.coordinator = mock_soco @@ -230,9 +228,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/sonos/test_number.py b/tests/components/sonos/test_number.py index 38456058d8a155..d58b84ab6cb0c1 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -6,6 +6,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +CROSSOVER_ENTITY = "number.zone_a_sub_crossover_frequency" + async def test_number_entities( hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry @@ -62,3 +64,32 @@ async def test_number_entities( blocking=True, ) mock_sub_gain.assert_called_once_with(-8) + + # sub_crossover is only available on Sonos Amp devices, see test_amp_number_entities + assert CROSSOVER_ENTITY not in entity_registry.entities + + +async def test_amp_number_entities( + hass: HomeAssistant, async_setup_sonos, soco, entity_registry: er.EntityRegistry +) -> None: + """Test the sub_crossover feature only available on Sonos Amp devices. + + The sub_crossover value will be None on all other device types. + """ + with patch.object(soco, "sub_crossover", 50): + await async_setup_sonos() + + sub_crossover_number = entity_registry.entities[CROSSOVER_ENTITY] + sub_crossover_state = hass.states.get(sub_crossover_number.entity_id) + assert sub_crossover_state.state == "50" + + with patch.object( + type(soco), "sub_crossover", new_callable=PropertyMock + ) as mock_sub_crossover: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: sub_crossover_number.entity_id, "value": 110}, + blocking=True, + ) + mock_sub_crossover.assert_called_once_with(110) diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 6a629f9603d91d..9cdd026bd3bf36 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -46,17 +46,104 @@ ENTRY_CONFIG_INVALID_QUERY = { CONF_NAME: "Get Value", - CONF_QUERY: "UPDATE 5 as value", + CONF_QUERY: "SELECT 5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_2 = { + CONF_NAME: "Get Value", + CONF_QUERY: "SELECT5 FROM as value", CONF_COLUMN_NAME: "size", CONF_UNIT_OF_MEASUREMENT: "MiB", } + +ENTRY_CONFIG_INVALID_QUERY_3 = { + CONF_NAME: "Get Value", + CONF_QUERY: ";;", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + ENTRY_CONFIG_INVALID_QUERY_OPT = { + CONF_QUERY: "SELECT 5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_2_OPT = { + CONF_QUERY: "SELECT5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_3_OPT = { + CONF_QUERY: ";;", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_QUERY_READ_ONLY_CTE = { + CONF_NAME: "Get Value", + CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_NO_READ_ONLY = { + CONF_NAME: "Get Value", + CONF_QUERY: "UPDATE states SET state = 999999 WHERE state_id = 11125", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE = { + CONF_NAME: "Get Value", + CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_READ_ONLY_CTE_OPT = { + CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT = { CONF_QUERY: "UPDATE 5 as value", CONF_COLUMN_NAME: "size", CONF_UNIT_OF_MEASUREMENT: "MiB", } +ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT = { + CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_MULTIPLE_QUERIES = { + CONF_NAME: "Get Value", + CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_MULTIPLE_QUERIES_OPT = { + CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + ENTRY_CONFIG_INVALID_COLUMN_NAME = { CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 6517e319fe4a41..43608d0d32a017 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -17,8 +17,18 @@ ENTRY_CONFIG_INVALID_COLUMN_NAME, ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT, ENTRY_CONFIG_INVALID_QUERY, + ENTRY_CONFIG_INVALID_QUERY_2, + ENTRY_CONFIG_INVALID_QUERY_2_OPT, + ENTRY_CONFIG_INVALID_QUERY_3, + ENTRY_CONFIG_INVALID_QUERY_3_OPT, ENTRY_CONFIG_INVALID_QUERY_OPT, + ENTRY_CONFIG_MULTIPLE_QUERIES, + ENTRY_CONFIG_MULTIPLE_QUERIES_OPT, ENTRY_CONFIG_NO_RESULTS, + ENTRY_CONFIG_QUERY_NO_READ_ONLY, + ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE, + ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT, + ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT, ENTRY_CONFIG_WITH_VALUE_TEMPLATE, ) @@ -132,6 +142,56 @@ async def test_flow_fails_invalid_query( "query": "query_invalid", } + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_2, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "query_invalid", + } + + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_3, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "query_invalid", + } + + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY, + ) + + assert result5["type"] == FlowResultType.FORM + assert result5["errors"] == { + "query": "query_no_read_only", + } + + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "query_no_read_only", + } + + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_MULTIPLE_QUERIES, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "multiple_queries", + } + result5 = await hass.config_entries.flow.async_configure( result4["flow_id"], user_input=ENTRY_CONFIG_NO_RESULTS, @@ -380,6 +440,56 @@ async def test_options_flow_fails_invalid_query( "query": "query_invalid", } + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_2_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "query_invalid", + } + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_3_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "query_invalid", + } + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == { + "query": "query_no_read_only", + } + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "query_no_read_only", + } + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_MULTIPLE_QUERIES_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "multiple_queries", + } + result4 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ diff --git a/tests/components/sql/test_init.py b/tests/components/sql/test_init.py index 50de8aba7b3197..2ae6010e0c5f18 100644 --- a/tests/components/sql/test_init.py +++ b/tests/components/sql/test_init.py @@ -58,6 +58,32 @@ async def test_invalid_query(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): validate_sql_select("DROP TABLE *") + with pytest.raises(vol.Invalid): + validate_sql_select("SELECT5 as value") + + with pytest.raises(vol.Invalid): + validate_sql_select(";;") + + +async def test_query_no_read_only(hass: HomeAssistant) -> None: + """Test query no read only.""" + with pytest.raises(vol.Invalid): + validate_sql_select("UPDATE states SET state = 999999 WHERE state_id = 11125") + + +async def test_query_no_read_only_cte(hass: HomeAssistant) -> None: + """Test query no read only CTE.""" + with pytest.raises(vol.Invalid): + validate_sql_select( + "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;" + ) + + +async def test_multiple_queries(hass: HomeAssistant) -> None: + """Test multiple queries.""" + with pytest.raises(vol.Invalid): + validate_sql_select("SELECT 5 as value; UPDATE states SET state = 10;") + async def test_remove_configured_db_url_if_not_needed_when_not_needed( recorder_mock: Recorder, diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index cb988d3f2d47c1..9ac22f483125e9 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy import text as sql_text from sqlalchemy.exc import SQLAlchemyError @@ -12,6 +13,7 @@ from homeassistant.components.recorder import Recorder from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sql.const import CONF_QUERY, DOMAIN +from homeassistant.components.sql.sensor import _generate_lambda_stmt from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_ICON, @@ -21,6 +23,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -54,6 +57,22 @@ async def test_query_basic(recorder_mock: Recorder, hass: HomeAssistant) -> None assert state.attributes["value"] == 5 +async def test_query_cte(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test the SQL sensor with CTE.""" + config = { + "db_url": "sqlite://", + "query": "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + "column": "state", + "name": "Select value SQL query CTE", + "unique_id": "very_unique_id", + } + await init_integration(hass, config) + + state = hass.states.get("sensor.select_value_sql_query_cte") + assert state.state == "10" + assert state.attributes["state"] == 10 + + async def test_query_value_template( recorder_mock: Recorder, hass: HomeAssistant ) -> None: @@ -570,3 +589,48 @@ async def test_attributes_from_entry_config( assert state.attributes["unit_of_measurement"] == "MiB" assert "device_class" not in state.attributes assert "state_class" not in state.attributes + + +async def test_query_recover_from_rollback( + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the SQL sensor.""" + config = { + "db_url": "sqlite://", + "query": "SELECT 5 as value", + "column": "value", + "name": "Select value SQL query", + "unique_id": "very_unique_id", + } + await init_integration(hass, config) + platforms = async_get_platforms(hass, "sql") + sql_entity = platforms[0].entities["sensor.select_value_sql_query"] + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes["value"] == 5 + + with patch.object( + sql_entity, + "_lambda_stmt", + _generate_lambda_stmt("Faulty syntax create operational issue"), + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert "sqlite3.OperationalError" in caplog.text + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes.get("value") is None + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes.get("value") == 5 diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index 87ba2d3be7399f..f12c7750cdf3e2 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,7 +6,7 @@ from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.squeezebox.const import DOMAIN +from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -59,7 +59,13 @@ async def test_user_form(hass: HomeAssistant) -> None: # test the edit step result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: HOST, CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: ""}, + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == HOST @@ -68,6 +74,7 @@ async def test_user_form(hass: HomeAssistant) -> None: CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: "", + CONF_HTTPS: False, } await hass.async_block_till_done() @@ -107,7 +114,11 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: "homeassistant.components.squeezebox.async_setup_entry", return_value=True, ): - entry = MockConfigEntry(domain=DOMAIN, unique_id=UUID) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UUID, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, + ) await hass.config_entries.async_add(entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -186,7 +197,7 @@ async def test_discovery_no_uuid(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "edit" diff --git a/tests/components/srp_energy/__init__.py b/tests/components/srp_energy/__init__.py index 99a5da84fe286e..634d589195e341 100644 --- a/tests/components/srp_energy/__init__.py +++ b/tests/components/srp_energy/__init__.py @@ -1,21 +1,36 @@ """Tests for the SRP Energy integration.""" +from typing import Final + from homeassistant.components.srp_energy.const import CONF_IS_TOU -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME + +ACCNT_ID: Final = "123456789" +ACCNT_IS_TOU: Final = False +ACCNT_USERNAME: Final = "test_username" +ACCNT_PASSWORD: Final = "test_password" +ACCNT_NAME: Final = "Test Home" -ACCNT_ID = "123456789" -ACCNT_IS_TOU = False -ACCNT_USERNAME = "abba" -ACCNT_PASSWORD = "ana" -ACCNT_NAME = "Home" -TEST_USER_INPUT = { +TEST_CONFIG_HOME: Final[dict[str, str]] = { + CONF_NAME: ACCNT_NAME, CONF_ID: ACCNT_ID, CONF_USERNAME: ACCNT_USERNAME, CONF_PASSWORD: ACCNT_PASSWORD, CONF_IS_TOU: ACCNT_IS_TOU, } +ACCNT_ID_2: Final = "987654321" +ACCNT_NAME_2: Final = "Test Cabin" + +TEST_CONFIG_CABIN: Final[dict[str, str]] = { + CONF_NAME: ACCNT_NAME_2, + CONF_ID: ACCNT_ID_2, + CONF_USERNAME: ACCNT_USERNAME, + CONF_PASSWORD: ACCNT_PASSWORD, + CONF_IS_TOU: ACCNT_IS_TOU, +} + MOCK_USAGE = [ ("7/31/2022", "00:00 AM", "2022-07-31T00:00:00", "1.2", "0.19"), ("7/31/2022", "01:00 AM", "2022-07-31T01:00:00", "1.3", "0.20"), diff --git a/tests/components/srp_energy/conftest.py b/tests/components/srp_energy/conftest.py index 3ffebe167c2a85..e3597081d77404 100644 --- a/tests/components/srp_energy/conftest.py +++ b/tests/components/srp_energy/conftest.py @@ -9,10 +9,11 @@ import pytest from homeassistant.components.srp_energy.const import DOMAIN, PHOENIX_TIME_ZONE +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_USAGE, TEST_USER_INPUT +from . import MOCK_USAGE, TEST_CONFIG_HOME from tests.common import MockConfigEntry @@ -42,8 +43,7 @@ def fixture_test_date(hass: HomeAssistant, hass_tz_info) -> dt.datetime | None: def fixture_mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - domain=DOMAIN, - data=TEST_USER_INPUT, + domain=DOMAIN, data=TEST_CONFIG_HOME, unique_id=TEST_CONFIG_HOME[CONF_ID] ) @@ -81,7 +81,6 @@ async def init_integration( mock_srp_energy_config_flow, ) -> MockConfigEntry: """Set up the Srp Energy integration for testing.""" - freezer.move_to(test_date) mock_config_entry.add_to_hass(hass) diff --git a/tests/components/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py index dfd1d41e82023f..572b67259f1d12 100644 --- a/tests/components/srp_energy/test_config_flow.py +++ b/tests/components/srp_energy/test_config_flow.py @@ -1,13 +1,23 @@ """Test the SRP Energy config flow.""" from unittest.mock import MagicMock, patch -from homeassistant import config_entries from homeassistant.components.srp_energy.const import CONF_IS_TOU, DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_SOURCE, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import ACCNT_ID, ACCNT_IS_TOU, ACCNT_PASSWORD, ACCNT_USERNAME, TEST_USER_INPUT +from . import ( + ACCNT_ID, + ACCNT_ID_2, + ACCNT_IS_TOU, + ACCNT_NAME, + ACCNT_NAME_2, + ACCNT_PASSWORD, + ACCNT_USERNAME, + TEST_CONFIG_CABIN, + TEST_CONFIG_HOME, +) from tests.common import MockConfigEntry @@ -17,7 +27,7 @@ async def test_show_form( ) -> None: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) assert result["type"] == FlowResultType.FORM @@ -29,12 +39,12 @@ async def test_show_form( return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "test home" + assert result["title"] == ACCNT_NAME assert "data" in result assert result["data"][CONF_ID] == ACCNT_ID @@ -56,11 +66,11 @@ async def test_form_invalid_account( mock_srp_energy_config_flow.validate.side_effect = ValueError result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) assert result["type"] == FlowResultType.FORM @@ -75,11 +85,11 @@ async def test_form_invalid_auth( mock_srp_energy_config_flow.validate.return_value = False result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) assert result["type"] == FlowResultType.FORM @@ -94,11 +104,11 @@ async def test_form_unknown_error( mock_srp_energy_config_flow.validate.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) assert result["type"] == FlowResultType.ABORT @@ -109,18 +119,52 @@ async def test_flow_entry_already_configured( hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test user input for config_entry that already exists.""" - user_input = { - CONF_ID: init_integration.data[CONF_ID], - CONF_USERNAME: "abba2", - CONF_PASSWORD: "ana2", - CONF_IS_TOU: False, - } + # Verify mock config setup from fixture + assert init_integration.state == ConfigEntryState.LOADED + assert init_integration.data[CONF_ID] == ACCNT_ID + assert init_integration.unique_id == ACCNT_ID - assert user_input[CONF_ID] == ACCNT_ID + # Attempt a second config using same account id. This is the unique id between configs. + user_input_second = TEST_CONFIG_HOME + user_input_second[CONF_ID] = init_integration.data[CONF_ID] + + assert user_input_second[CONF_ID] == ACCNT_ID result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, data=user_input + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input_second ) assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" + + +async def test_flow_multiple_configs( + hass: HomeAssistant, init_integration: MockConfigEntry, capsys +) -> None: + """Test multiple config entries.""" + # Verify mock config setup from fixture + assert init_integration.state == ConfigEntryState.LOADED + assert init_integration.data[CONF_ID] == ACCNT_ID + assert init_integration.unique_id == ACCNT_ID + + # Attempt a second config using different account id. This is the unique id between configs. + assert TEST_CONFIG_CABIN[CONF_ID] != ACCNT_ID + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=TEST_CONFIG_CABIN + ) + + # Verify created + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ACCNT_NAME_2 + + assert "data" in result + assert result["data"][CONF_ID] == ACCNT_ID_2 + assert result["data"][CONF_USERNAME] == ACCNT_USERNAME + assert result["data"][CONF_PASSWORD] == ACCNT_PASSWORD + assert result["data"][CONF_IS_TOU] == ACCNT_IS_TOU + + # Verify multiple configs + entries = hass.config_entries.async_entries() + domain_entries = [entry for entry in entries if entry.domain == DOMAIN] + assert len(domain_entries) == 2 diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 32d2d971d2c3bb..2d49fd13bf14e9 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -28,7 +28,7 @@ async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: """Test the SrpEntity.""" - usage_state = hass.states.get("sensor.srp_energy_energy_usage") + usage_state = hass.states.get("sensor.srp_energy_mock_title_energy_usage") assert usage_state.state == "150.8" # Validate attributions @@ -61,7 +61,7 @@ async def test_srp_entity_update_failed( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - usage_state = hass.states.get("sensor.home_energy_usage") + usage_state = hass.states.get("sensor.srp_energy_mock_title_energy_usage") assert usage_state is None @@ -84,5 +84,5 @@ async def test_srp_entity_timeout( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - usage_state = hass.states.get("sensor.home_energy_usage") + usage_state = hass.states.get("sensor.srp_energy_mock_title_energy_usage") assert usage_state is None diff --git a/tests/components/streamlabswater/__init__.py b/tests/components/streamlabswater/__init__.py new file mode 100644 index 00000000000000..a467c9553dec70 --- /dev/null +++ b/tests/components/streamlabswater/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the StreamLabs integration.""" +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + hass.config.units = IMPERIAL_SYSTEM + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/streamlabswater/conftest.py b/tests/components/streamlabswater/conftest.py new file mode 100644 index 00000000000000..64fbed63520698 --- /dev/null +++ b/tests/components/streamlabswater/conftest.py @@ -0,0 +1,49 @@ +"""Common fixtures for the StreamLabs tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from streamlabswater.streamlabswater import StreamlabsClient + +from homeassistant.components.streamlabswater import DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.streamlabswater.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock StreamLabs config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="StreamLabs", + data={CONF_API_KEY: "abc"}, + ) + + +@pytest.fixture(name="streamlabswater") +def mock_streamlabswater() -> Generator[AsyncMock, None, None]: + """Mock the StreamLabs client.""" + + locations = load_json_object_fixture("streamlabswater/get_locations.json") + + water_usage = load_json_object_fixture("streamlabswater/water_usage.json") + + mock = AsyncMock(spec=StreamlabsClient) + mock.get_locations.return_value = locations + mock.get_water_usage_summary.return_value = water_usage + + with patch( + "homeassistant.components.streamlabswater.StreamlabsClient", + return_value=mock, + ) as mock_client: + yield mock_client diff --git a/tests/components/streamlabswater/fixtures/get_locations.json b/tests/components/streamlabswater/fixtures/get_locations.json new file mode 100644 index 00000000000000..bdf4deb1d1bd67 --- /dev/null +++ b/tests/components/streamlabswater/fixtures/get_locations.json @@ -0,0 +1,24 @@ +{ + "pageCount": 1, + "perPage": 50, + "page": 1, + "total": 1, + "locations": [ + { + "locationId": "945e7c52-854a-41e1-8524-50c6993277e1", + "name": "Water Monitor", + "homeAway": "home", + "devices": [ + { + "deviceId": "09bec87a-fff2-4b8a-bc00-86d5928f19f3", + "type": "monitor", + "calibrated": true, + "connected": true + } + ], + "alerts": [], + "subscriptionIds": [], + "active": true + } + ] +} diff --git a/tests/components/streamlabswater/fixtures/water_usage.json b/tests/components/streamlabswater/fixtures/water_usage.json new file mode 100644 index 00000000000000..1e902371f73657 --- /dev/null +++ b/tests/components/streamlabswater/fixtures/water_usage.json @@ -0,0 +1,6 @@ +{ + "thisYear": 65432.389256934, + "today": 200.44691536, + "units": "gallons", + "thisMonth": 420.514099294 +} diff --git a/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr b/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..2ca9b802bf51d5 --- /dev/null +++ b/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr @@ -0,0 +1,44 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.water_monitor_away_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.water_monitor_away_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Away mode', + 'platform': 'streamlabswater', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'away_mode', + 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-away_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.water_monitor_away_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Monitor Away mode', + }), + 'context': , + 'entity_id': 'binary_sensor.water_monitor_away_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/streamlabswater/snapshots/test_sensor.ambr b/tests/components/streamlabswater/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..9d8ca3a99e6199 --- /dev/null +++ b/tests/components/streamlabswater/snapshots/test_sensor.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_all_entities[sensor.water_monitor_daily_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_monitor_daily_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily usage', + 'platform': 'streamlabswater', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_usage', + 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-daily_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.water_monitor_daily_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water Monitor Daily usage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_monitor_daily_usage', + 'last_changed': , + 'last_updated': , + 'state': '200.44691536', + }) +# --- +# name: test_all_entities[sensor.water_monitor_monthly_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_monitor_monthly_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monthly usage', + 'platform': 'streamlabswater', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_usage', + 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-monthly_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.water_monitor_monthly_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water Monitor Monthly usage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_monitor_monthly_usage', + 'last_changed': , + 'last_updated': , + 'state': '420.514099294', + }) +# --- +# name: test_all_entities[sensor.water_monitor_yearly_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_monitor_yearly_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yearly usage', + 'platform': 'streamlabswater', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yearly_usage', + 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-yearly_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.water_monitor_yearly_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water Monitor Yearly usage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_monitor_yearly_usage', + 'last_changed': , + 'last_updated': , + 'state': '65432.389256934', + }) +# --- diff --git a/tests/components/streamlabswater/test_binary_sensor.py b/tests/components/streamlabswater/test_binary_sensor.py new file mode 100644 index 00000000000000..4f533d91b553c5 --- /dev/null +++ b/tests/components/streamlabswater/test_binary_sensor.py @@ -0,0 +1,35 @@ +"""Tests for the Streamlabs Water binary sensor platform.""" +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.streamlabswater import setup_integration + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + streamlabswater: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.streamlabswater.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) diff --git a/tests/components/streamlabswater/test_config_flow.py b/tests/components/streamlabswater/test_config_flow.py new file mode 100644 index 00000000000000..68f671d3b8c483 --- /dev/null +++ b/tests/components/streamlabswater/test_config_flow.py @@ -0,0 +1,193 @@ +"""Test the StreamLabs config flow.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries +from homeassistant.components.streamlabswater.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +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.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> 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.streamlabswater.config_flow.StreamlabsClient.get_locations", + return_value={}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unknown(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_entry_already_exists(hass: HomeAssistant) -> None: + """Test we handle if the entry already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "abc"}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test import flow.""" + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + return_value={}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_unknown(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle unknown error.""" + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_import_entry_already_exists(hass: HomeAssistant) -> None: + """Test we handle if the entry already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "abc"}, + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/streamlabswater/test_sensor.py b/tests/components/streamlabswater/test_sensor.py new file mode 100644 index 00000000000000..a78d4129abb63b --- /dev/null +++ b/tests/components/streamlabswater/test_sensor.py @@ -0,0 +1,33 @@ +"""Tests for the Streamlabs Water sensor platform.""" +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.streamlabswater import setup_integration + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + streamlabswater: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.streamlabswater.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 4100df94b9eea8..9764451c5d59d9 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -121,12 +121,20 @@ class STTFlow(ConfigFlow): """Test flow.""" +@pytest.fixture(name="config_flow_test_domain") +def config_flow_test_domain_fixture() -> str: + """Test domain fixture.""" + return TEST_DOMAIN + + @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture( + hass: HomeAssistant, config_flow_test_domain: str +) -> Generator[None, None, None]: """Mock config flow.""" - mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_platform(hass, f"{config_flow_test_domain}.config_flow") - with mock_config_flow(TEST_DOMAIN, STTFlow): + with mock_config_flow(config_flow_test_domain, STTFlow): yield @@ -137,6 +145,7 @@ async def setup_fixture( request: pytest.FixtureRequest, ) -> MockProvider | MockProviderEntity: """Set up the test environment.""" + provider: MockProvider | MockProviderEntity if request.param == "mock_setup": provider = MockProvider() await mock_setup(hass, tmp_path, provider) @@ -166,7 +175,10 @@ async def mock_setup( async def mock_config_entry_setup( - hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity + hass: HomeAssistant, + tmp_path: Path, + mock_provider_entity: MockProviderEntity, + test_domain: str = TEST_DOMAIN, ) -> MockConfigEntry: """Set up a test provider via config entry.""" @@ -187,7 +199,7 @@ async def async_unload_entry_init( mock_integration( hass, MockModule( - TEST_DOMAIN, + test_domain, async_setup_entry=async_setup_entry_init, async_unload_entry=async_unload_entry_init, ), @@ -201,9 +213,9 @@ async def async_setup_entry_platform( """Set up test stt platform via config entry.""" async_add_entities([mock_provider_entity]) - mock_stt_entity_platform(hass, tmp_path, TEST_DOMAIN, async_setup_entry_platform) + mock_stt_entity_platform(hass, tmp_path, test_domain, async_setup_entry_platform) - config_entry = MockConfigEntry(domain=TEST_DOMAIN) + 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() @@ -456,7 +468,11 @@ async def test_default_engine_none(hass: HomeAssistant, tmp_path: Path) -> None: assert async_default_engine(hass) is None -async def test_default_engine(hass: HomeAssistant, tmp_path: Path) -> None: +async def test_default_engine( + hass: HomeAssistant, + tmp_path: Path, + mock_provider: MockProvider, +) -> None: """Test async_default_engine.""" mock_stt_platform( hass, @@ -479,26 +495,31 @@ async def test_default_engine_entity( assert async_default_engine(hass) == f"{DOMAIN}.{TEST_DOMAIN}" -async def test_default_engine_prefer_cloud(hass: HomeAssistant, tmp_path: Path) -> None: +@pytest.mark.parametrize("config_flow_test_domain", ["new_test"]) +async def test_default_engine_prefer_provider( + hass: HomeAssistant, + tmp_path: Path, + mock_provider_entity: MockProviderEntity, + mock_provider: MockProvider, + config_flow_test_domain: str, +) -> None: """Test async_default_engine.""" - mock_stt_platform( - hass, - tmp_path, - TEST_DOMAIN, - async_get_engine=AsyncMock(return_value=mock_provider), - ) - mock_stt_platform( - hass, - tmp_path, - "cloud", - async_get_engine=AsyncMock(return_value=mock_provider), - ) - assert await async_setup_component( - hass, "stt", {"stt": [{"platform": TEST_DOMAIN}, {"platform": "cloud"}]} + mock_provider_entity.url_path = "stt.new_test" + mock_provider_entity._attr_name = "New test" + + await mock_setup(hass, tmp_path, mock_provider) + await mock_config_entry_setup( + hass, tmp_path, mock_provider_entity, test_domain=config_flow_test_domain ) await hass.async_block_till_done() - assert async_default_engine(hass) == "cloud" + entity_engine = async_get_speech_to_text_engine(hass, "stt.new_test") + assert entity_engine is not None + assert entity_engine.name == "New test" + provider_engine = async_get_speech_to_text_engine(hass, "test") + assert provider_engine is not None + assert provider_engine.name == "test" + assert async_default_engine(hass) == "test" async def test_get_engine_legacy( diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 678e8ba5034201..4927525d896e8b 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -8,7 +8,6 @@ from homeassistant import config_entries from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.subaru.const import ( - CONF_COUNTRY, CONF_UPDATE_ENABLED, DOMAIN, FETCH_INTERVAL, @@ -22,7 +21,13 @@ VEHICLE_NAME, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_PIN, + CONF_USERNAME, +) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -145,9 +150,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/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index c3df10ed6181d1..7e892d2c99a5a4 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -130,6 +130,7 @@ async def test_user_form_pin_not_required( "version": 1, "data": deepcopy(TEST_CONFIG), "options": {}, + "minor_version": 1, } expected["data"][CONF_PIN] = None @@ -316,6 +317,7 @@ async def test_pin_form_success(hass: HomeAssistant, pin_form) -> None: "version": 1, "data": TEST_CONFIG, "options": {}, + "minor_version": 1, } result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID assert result == expected diff --git a/tests/components/suez_water/__init__.py b/tests/components/suez_water/__init__.py new file mode 100644 index 00000000000000..4605e06344add2 --- /dev/null +++ b/tests/components/suez_water/__init__.py @@ -0,0 +1 @@ +"""Tests for the Suez Water integration.""" diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py new file mode 100644 index 00000000000000..8a67cfe97d7c3f --- /dev/null +++ b/tests/components/suez_water/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Suez Water 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.suez_water.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py new file mode 100644 index 00000000000000..c18b8a927e9275 --- /dev/null +++ b/tests/components/suez_water/test_config_flow.py @@ -0,0 +1,211 @@ +"""Test the Suez Water config flow.""" +from unittest.mock import AsyncMock, patch + +from pysuez.client import PySuezError +import pytest + +from homeassistant import config_entries +from homeassistant.components.suez_water.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_DATA = { + "username": "test-username", + "password": "test-password", + "counter_id": "test-counter", +} + + +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.suez_water.config_flow.SuezClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> 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.suez_water.config_flow.SuezClient.__init__", + return_value=None, + ), patch( + "homeassistant.components.suez_water.config_flow.SuezClient.check_credentials", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch("homeassistant.components.suez_water.config_flow.SuezClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured(hass: HomeAssistant) -> None: + """Test we abort when entry is already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data=MOCK_DATA, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), [(PySuezError, "cannot_connect"), (Exception, "unknown")] +) +async def test_form_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock, exception: Exception, error: str +) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test import flow.""" + with patch("homeassistant.components.suez_water.config_flow.SuezClient"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "reason"), [(PySuezError, "cannot_connect"), (Exception, "unknown")] +) +async def test_import_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle errors while importing.""" + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_importing_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth when importing.""" + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient.__init__", + return_value=None, + ), patch( + "homeassistant.components.suez_water.config_flow.SuezClient.check_credentials", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_auth" + + +async def test_import_already_configured(hass: HomeAssistant) -> None: + """Test we abort import when entry is already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data=MOCK_DATA, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/sunweg/__init__.py b/tests/components/sunweg/__init__.py new file mode 100644 index 00000000000000..1453483a3fd560 --- /dev/null +++ b/tests/components/sunweg/__init__.py @@ -0,0 +1 @@ +"""Tests for the sunweg component.""" diff --git a/tests/components/sunweg/common.py b/tests/components/sunweg/common.py new file mode 100644 index 00000000000000..616f5c0137fd10 --- /dev/null +++ b/tests/components/sunweg/common.py @@ -0,0 +1,21 @@ +"""Common functions needed to setup tests for Sun WEG.""" + +from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +SUNWEG_USER_INPUT = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", +} + +SUNWEG_MOCK_ENTRY = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_PLANT_ID: 0, + CONF_NAME: "Name", + }, +) diff --git a/tests/components/sunweg/conftest.py b/tests/components/sunweg/conftest.py new file mode 100644 index 00000000000000..68c4cab86c5b59 --- /dev/null +++ b/tests/components/sunweg/conftest.py @@ -0,0 +1,70 @@ +"""Conftest for SunWEG tests.""" + +from datetime import datetime + +import pytest +from sunweg.device import MPPT, Inverter, Phase, String +from sunweg.plant import Plant + + +@pytest.fixture +def string_fixture() -> String: + """Define String fixture.""" + return String("STR1", 450.3, 23.4, 0) + + +@pytest.fixture +def mppt_fixture(string_fixture) -> MPPT: + """Define MPPT fixture.""" + mppt = MPPT("mppt") + mppt.strings.append(string_fixture) + return mppt + + +@pytest.fixture +def phase_fixture() -> Phase: + """Define Phase fixture.""" + return Phase("PhaseA", 120.0, 3.2, 0, 0) + + +@pytest.fixture +def inverter_fixture(phase_fixture, mppt_fixture) -> Inverter: + """Define inverter fixture.""" + inverter = Inverter( + 21255, + "INVERSOR01", + "J63T233018RE074", + 23.2, + 0.0, + 0.0, + "MWh", + 0, + "kWh", + 0.0, + 1, + 0, + "kW", + ) + inverter.phases.append(phase_fixture) + inverter.mppts.append(mppt_fixture) + return inverter + + +@pytest.fixture +def plant_fixture(inverter_fixture) -> Plant: + """Define Plant fixture.""" + plant = Plant( + 123456, + "Plant #123", + 29.5, + 0.5, + 0, + 12.786912, + 24.0, + "kWh", + 332.2, + 0.012296, + datetime(2023, 2, 16, 14, 22, 37), + ) + plant.inverters.append(inverter_fixture) + return plant diff --git a/tests/components/sunweg/test_config_flow.py b/tests/components/sunweg/test_config_flow.py new file mode 100644 index 00000000000000..1298d7e93fbdf6 --- /dev/null +++ b/tests/components/sunweg/test_config_flow.py @@ -0,0 +1,129 @@ +"""Tests for the Sun WEG server config flow.""" +from unittest.mock import patch + +from sunweg.api import APIHelper + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .common import SUNWEG_USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_show_authenticate_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_incorrect_login(hass: HomeAssistant) -> None: + """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch.object(APIHelper, "authenticate", return_value=False): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], SUNWEG_USER_INPUT + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_no_plants_on_account(hass: HomeAssistant) -> None: + """Test registering an integration with no plants available.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, "listPlants", return_value=[] + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], SUNWEG_USER_INPUT + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_plants" + + +async def test_multiple_plant_ids(hass: HomeAssistant, plant_fixture) -> None: + """Test registering an integration and finishing flow with an selected plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = SUNWEG_USER_INPUT.copy() + plant_list = [plant_fixture, plant_fixture] + + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, "listPlants", return_value=plant_list + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "plant" + + user_input = {CONF_PLANT_ID: 123456} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == 123456 + + +async def test_one_plant_on_account(hass: HomeAssistant, plant_fixture) -> None: + """Test registering an integration and finishing flow with current plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = SUNWEG_USER_INPUT.copy() + + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, + "listPlants", + return_value=[plant_fixture], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == 123456 + + +async def test_existing_plant_configured(hass: HomeAssistant, plant_fixture) -> None: + """Test entering an existing plant_id.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=123456) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = SUNWEG_USER_INPUT.copy() + + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, + "listPlants", + return_value=[plant_fixture], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py new file mode 100644 index 00000000000000..0295e778f9c487 --- /dev/null +++ b/tests/components/sunweg/test_init.py @@ -0,0 +1,177 @@ +"""Tests for the Sun WEG init.""" + +import json +from unittest.mock import MagicMock, patch + +from sunweg.api import APIHelper, SunWegApiError + +from homeassistant.components.sunweg import SunWEGData +from homeassistant.components.sunweg.const import DOMAIN, DeviceType +from homeassistant.components.sunweg.sensor_types.sensor_entity_description import ( + SunWEGSensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import SUNWEG_MOCK_ENTRY + + +async def test_methods(hass: HomeAssistant, plant_fixture, inverter_fixture) -> None: + """Test methods.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, "listPlants", return_value=[plant_fixture] + ), patch.object(APIHelper, "plant", return_value=plant_fixture), patch.object( + APIHelper, "inverter", return_value=inverter_fixture + ), patch.object(APIHelper, "complete_inverter"): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(mock_entry.entry_id) + + +async def test_setup_wrongpass(hass: HomeAssistant) -> None: + """Test setup with wrong pass.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + with patch.object(APIHelper, "authenticate", return_value=False): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + + +async def test_setup_error_500(hass: HomeAssistant) -> None: + """Test setup with wrong pass.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + with patch.object( + APIHelper, "authenticate", side_effect=SunWegApiError("Error 500") + ): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + + +async def test_sunwegdata_update_exception() -> None: + """Test SunWEGData exception on update.""" + api = MagicMock() + api.plant = MagicMock(side_effect=json.decoder.JSONDecodeError("Message", "Doc", 1)) + data = SunWEGData(api, 0) + data.update() + assert data.data is None + + +async def test_sunwegdata_update_success(plant_fixture) -> None: + """Test SunWEGData success on update.""" + api = MagicMock() + api.plant = MagicMock(return_value=plant_fixture) + api.complete_inverter = MagicMock() + data = SunWEGData(api, 0) + data.update() + assert data.data.id == plant_fixture.id + assert data.data.name == plant_fixture.name + assert data.data.kwh_per_kwp == plant_fixture.kwh_per_kwp + assert data.data.last_update == plant_fixture.last_update + assert data.data.performance_rate == plant_fixture.performance_rate + assert data.data.saving == plant_fixture.saving + assert len(data.data.inverters) == 1 + + +async def test_sunwegdata_get_api_value_none(plant_fixture) -> None: + """Test SunWEGData none return on get_api_value.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.data = plant_fixture + assert data.get_api_value("variable", DeviceType.INVERTER, 0, "deep_name") is None + assert data.get_api_value("variable", DeviceType.STRING, 21255, "deep_name") is None + + +async def test_sunwegdata_get_data_drop_threshold() -> None: + """Test SunWEGData get_data with drop threshold.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.get_api_value = MagicMock() + entity_description = SunWEGSensorEntityDescription( + api_variable_key="variable", key="key", previous_value_drop_threshold=0.1 + ) + data.get_api_value.return_value = 3.0 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) + data.get_api_value.return_value = 2.91 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) + data.get_api_value.return_value = 2.8 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (2.8, None) + + +async def test_sunwegdata_get_data_never_reset() -> None: + """Test SunWEGData get_data with never reset.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.get_api_value = MagicMock() + entity_description = SunWEGSensorEntityDescription( + api_variable_key="variable", key="key", never_resets=True + ) + data.get_api_value.return_value = 3.0 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) + data.get_api_value.return_value = 0 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) + data.get_api_value.return_value = 2.8 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (2.8, None) diff --git a/tests/components/swiss_public_transport/__init__.py b/tests/components/swiss_public_transport/__init__.py new file mode 100644 index 00000000000000..3859a630c31b2d --- /dev/null +++ b/tests/components/swiss_public_transport/__init__.py @@ -0,0 +1 @@ +"""Tests for the swiss_public_transport integration.""" diff --git a/tests/components/swiss_public_transport/conftest.py b/tests/components/swiss_public_transport/conftest.py new file mode 100644 index 00000000000000..d84446db0860eb --- /dev/null +++ b/tests/components/swiss_public_transport/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the swiss_public_transport 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.swiss_public_transport.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/swiss_public_transport/test_config_flow.py b/tests/components/swiss_public_transport/test_config_flow.py new file mode 100644 index 00000000000000..55ad51c45c4e1c --- /dev/null +++ b/tests/components/swiss_public_transport/test_config_flow.py @@ -0,0 +1,200 @@ +"""Test the swiss_public_transport config flow.""" +from unittest.mock import AsyncMock, patch + +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.swiss_public_transport import config_flow +from homeassistant.components.swiss_public_transport.const import ( + CONF_DESTINATION, + CONF_START, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +MOCK_DATA_STEP = { + CONF_START: "test_start", + CONF_DESTINATION: "test_destination", +} + + +async def test_flow_user_init_data_success(hass: HomeAssistant) -> None: + """Test success response.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["handler"] == "swiss_public_transport" + assert result["data_schema"] == config_flow.DATA_SCHEMA + + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "create_entry" + assert result["result"].title == "test_start test_destination" + + assert result["data"] == MOCK_DATA_STEP + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (OpendataTransportConnectionError(), "cannot_connect"), + (OpendataTransportError(), "bad_config"), + (IndexError(), "unknown"), + ], +) +async def test_flow_user_init_data_unknown_error_and_recover( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test unknown errors.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + side_effect=raise_error, + ) as mock_OpendataTransport: + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == text_error + + # Recover + mock_OpendataTransport.side_effect = None + mock_OpendataTransport.return_value = True + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "create_entry" + assert result["result"].title == "test_start test_destination" + + assert result["data"] == MOCK_DATA_STEP + + +async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> None: + """Test we abort user data set when entry is already configured.""" + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data=MOCK_DATA_STEP, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +MOCK_DATA_IMPORT = { + CONF_START: "test_start", + CONF_DESTINATION: "test_destination", + CONF_NAME: "test_name", +} + + +async def test_import( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test import flow.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_DATA_IMPORT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (OpendataTransportConnectionError(), "cannot_connect"), + (OpendataTransportError(), "bad_config"), + (IndexError(), "unknown"), + ], +) +async def test_import_cannot_connect_error( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test import flow cannot_connect error.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + side_effect=raise_error, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == text_error + + +async def test_import_already_configured(hass: HomeAssistant) -> None: + """Test we abort import when entry is already configured.""" + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data=MOCK_DATA_IMPORT, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index cbc91d24e41615..7a43e0bf50ef86 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -9,7 +9,7 @@ from . import common -from tests.common import MockUser +from tests.common import MockUser, import_and_test_deprecated_constant_enum @pytest.fixture(autouse=True) @@ -80,3 +80,14 @@ async def test_switch_context( assert state2 is not None assert state.state != state2.state assert state2.context.user_id == hass_admin_user.id + + +@pytest.mark.parametrize(("enum"), list(switch.SwitchDeviceClass)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: switch.SwitchDeviceClass, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, switch, enum, "DEVICE_CLASS_", "2025.1" + ) diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index 412cbc4333b2ca..51efbf99892a17 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -20,6 +20,7 @@ Platform.LIGHT, Platform.LOCK, Platform.SIREN, + Platform.VALVE, ) diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index a0c0bfca8254d6..738127faf43909 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -36,6 +36,7 @@ Platform.LIGHT, Platform.LOCK, Platform.SIREN, + Platform.VALVE, ) @@ -72,6 +73,7 @@ async def test_config_entry_unregistered_uuid( (Platform.LIGHT, STATE_ON, STATE_OFF), (Platform.LOCK, STATE_UNLOCKED, STATE_LOCKED), (Platform.SIREN, STATE_ON, STATE_OFF), + (Platform.VALVE, STATE_OPEN, STATE_CLOSED), ), ) async def test_entity_registry_events( diff --git a/tests/components/switch_as_x/test_valve.py b/tests/components/switch_as_x/test_valve.py new file mode 100644 index 00000000000000..da20c544f644d9 --- /dev/null +++ b/tests/components/switch_as_x/test_valve.py @@ -0,0 +1,122 @@ +"""Tests for the Switch as X Valve platform.""" +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN +from homeassistant.const import ( + CONF_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_CLOSED, + STATE_OFF, + STATE_ON, + STATE_OPEN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_default_state(hass: HomeAssistant) -> None: + """Test valve switch default state.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.test", + CONF_TARGET_DOMAIN: Platform.VALVE, + }, + title="Garage Door", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("valve.garage_door") + assert state is not None + assert state.state == "unavailable" + assert state.attributes["supported_features"] == 3 + + +async def test_service_calls(hass: HomeAssistant) -> None: + """Test service calls to valve.""" + await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.decorative_lights", + CONF_TARGET_DOMAIN: Platform.VALVE, + }, + title="Title is ignored", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN diff --git a/tests/components/switchbee/test_config_flow.py b/tests/components/switchbee/test_config_flow.py index 239777a4da35ca..98d413c3b9652f 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/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py index f998dbe294b8ff..1919261109e6a7 100644 --- a/tests/components/switcher_kis/test_climate.py +++ b/tests/components/switcher_kis/test_climate.py @@ -25,7 +25,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util import slugify from . import init_integration @@ -336,9 +336,8 @@ async def test_climate_control_errors( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 24}, blocking=True, ) - # Test exception when trying set fan level - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -347,7 +346,7 @@ async def test_climate_control_errors( ) # Test exception when trying set swing mode - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index 0af89cd238c328..f238bceb39ef35 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -48,6 +48,7 @@ async def test_diagnostics( "entry": { "entry_id": entry.entry_id, "version": 1, + "minor_version": 1, "domain": "switcher_kis", "title": "Mock Title", "data": {}, diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index 39ecc95d89e574..ff517b8963dec6 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -152,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, @@ -450,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, @@ -484,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/systemmonitor/__init__.py b/tests/components/systemmonitor/__init__.py new file mode 100644 index 00000000000000..92e60c1dbb2a87 --- /dev/null +++ b/tests/components/systemmonitor/__init__.py @@ -0,0 +1 @@ +"""Tests for the System Monitor component.""" diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py new file mode 100644 index 00000000000000..ca21c971cf1f69 --- /dev/null +++ b/tests/components/systemmonitor/conftest.py @@ -0,0 +1,17 @@ +"""Fixtures for the System Monitor integration.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setup entry.""" + with patch( + "homeassistant.components.systemmonitor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/systemmonitor/test_config_flow.py b/tests/components/systemmonitor/test_config_flow.py new file mode 100644 index 00000000000000..367d38b91aaa64 --- /dev/null +++ b/tests/components/systemmonitor/test_config_flow.py @@ -0,0 +1,270 @@ +"""Test the System Monitor config flow.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant import config_entries +from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.components.systemmonitor.const import CONF_PROCESS, DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.util import slugify + +from tests.common import MockConfigEntry + + +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["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["options"] == {} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import( + hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry +) -> None: + """Test import.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "processes": ["systemd", "octave-cli"], + "legacy_resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["options"] == { + "sensor": {"process": ["systemd", "octave-cli"]}, + "resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + } + + assert len(mock_setup_entry.mock_calls) == 1 + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue.issue_domain == DOMAIN + assert issue.translation_placeholders == { + "domain": DOMAIN, + "integration_title": "System Monitor", + } + + +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test abort when already configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_USER, + options={}, + ) + config_entry.add_to_hass(hass) + + 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"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry +) -> None: + """Test abort when already configured for import.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_USER, + options={ + "sensor": [{CONF_PROCESS: "systemd"}], + "resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + }, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "processes": ["systemd", "octave-cli"], + "legacy_resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue.issue_domain == DOMAIN + assert issue.translation_placeholders == { + "domain": DOMAIN, + "integration_title": "System Monitor", + } + + +async def test_add_and_remove_processes( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test adding and removing process sensors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_USER, + options={}, + entry_id="1", + ) + config_entry.add_to_hass(hass) + + 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"], + user_input={ + CONF_PROCESS: ["systemd"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["systemd"], + } + } + + # Add another + 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"], + user_input={ + CONF_PROCESS: ["systemd", "octave-cli"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["systemd", "octave-cli"], + }, + } + + entity_reg = er.async_get(hass) + entity_reg.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=slugify("process_systemd"), + config_entry=config_entry, + ) + entity_reg.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=slugify("process_octave-cli"), + config_entry=config_entry, + ) + assert entity_reg.async_get("sensor.systemmonitor_process_systemd") is not None + assert entity_reg.async_get("sensor.systemmonitor_process_octave_cli") is not None + + # Remove one + 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"], + user_input={ + CONF_PROCESS: ["systemd"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["systemd"], + }, + } + + # Remove last + 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"], + user_input={ + CONF_PROCESS: [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": {CONF_PROCESS: []}, + } + + assert entity_reg.async_get("sensor.systemmonitor_process_systemd") is None + assert entity_reg.async_get("sensor.systemmonitor_process_octave_cli") is None diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index c4a39914e53a23..ac04777dc1c997 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -3,6 +3,7 @@ from ipaddress import ip_address from unittest.mock import MagicMock, patch +import PyTado import pytest import requests @@ -260,3 +261,141 @@ async def test_form_homekit(hass: HomeAssistant) -> None: ), ) assert result["type"] == "abort" + + +async def test_import_step(hass: HomeAssistant) -> None: + """Test import step.""" + mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + + with patch( + "homeassistant.components.tado.config_flow.Tado", + return_value=mock_tado_api, + ), patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "username": "test-username", + "password": "test-password", + "home_id": "1", + } + assert mock_setup_entry.call_count == 1 + + +async def test_import_step_existing_entry(hass: HomeAssistant) -> None: + """Test import step with existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_setup_entry.call_count == 0 + + +async def test_import_step_validation_failed(hass: HomeAssistant) -> None: + """Test import step with validation failed.""" + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=RuntimeError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "import_failed" + + +async def test_import_step_device_authentication_failed(hass: HomeAssistant) -> None: + """Test import step with device tracker authentication failed.""" + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=PyTado.exceptions.TadoWrongCredentialsException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "import_failed_invalid_auth" + + +async def test_import_step_unique_id_configured(hass: HomeAssistant) -> None: + """Test import step with unique ID already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + unique_id="unique_id", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_setup_entry.call_count == 0 diff --git a/tests/components/tag/test_event.py b/tests/components/tag/test_event.py new file mode 100644 index 00000000000000..0338ed504d776d --- /dev/null +++ b/tests/components/tag/test_event.py @@ -0,0 +1,112 @@ +"""Tests for the tag component.""" + +from freezegun.api import FrozenDateTimeFactory +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, + freezer: FrozenDateTimeFactory, + 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() + freezer.move_to(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, + freezer: FrozenDateTimeFactory, + 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() + freezer.move_to(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 3e034d2b9f23c6..d7f77c0d2e2b9c 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -1,6 +1,6 @@ """Tests for the tag component.""" -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.tag import DOMAIN, TAGS, async_scan_tag @@ -76,7 +76,10 @@ async def test_ws_update( async def test_tag_scanned( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + storage_setup, ) -> None: """Test scanning tags.""" assert await storage_setup() @@ -93,8 +96,8 @@ async def test_tag_scanned( assert "test tag" in result now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - await async_scan_tag(hass, "new tag", "some_scanner") + freezer.move_to(now) + await async_scan_tag(hass, "new tag", "some_scanner") await client.send_json({"id": 7, "type": f"{DOMAIN}/list"}) resp = await client.receive_json() @@ -131,5 +134,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/tailwind/__init__.py b/tests/components/tailwind/__init__.py new file mode 100644 index 00000000000000..48c1de3d421dd5 --- /dev/null +++ b/tests/components/tailwind/__init__.py @@ -0,0 +1 @@ +"""Integration tests for the Tailwind integration.""" diff --git a/tests/components/tailwind/conftest.py b/tests/components/tailwind/conftest.py new file mode 100644 index 00000000000000..b39a3598a3ee90 --- /dev/null +++ b/tests/components/tailwind/conftest.py @@ -0,0 +1,74 @@ +"""Fixtures for the Tailwind integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from gotailwind import TailwindDeviceStatus +import pytest + +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def device_fixture() -> str: + """Return the device fixtures for a specific device.""" + return "iq3" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Tailwind iQ3", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.127", + CONF_TOKEN: "123456", + }, + unique_id="3c:e9:0e:6d:21:84", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.tailwind.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_tailwind(device_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked Tailwind client.""" + with patch( + "homeassistant.components.tailwind.coordinator.Tailwind", autospec=True + ) as tailwind_mock, patch( + "homeassistant.components.tailwind.config_flow.Tailwind", + new=tailwind_mock, + ): + tailwind = tailwind_mock.return_value + tailwind.status.return_value = TailwindDeviceStatus.from_json( + load_fixture(f"{device_fixture}.json", DOMAIN) + ) + yield tailwind + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> MockConfigEntry: + """Set up the Tailwind 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 diff --git a/tests/components/tailwind/fixtures/iq3.json b/tests/components/tailwind/fixtures/iq3.json new file mode 100644 index 00000000000000..1c8b2d5e0d43af --- /dev/null +++ b/tests/components/tailwind/fixtures/iq3.json @@ -0,0 +1,24 @@ +{ + "result": "OK", + "product": "iQ3", + "dev_id": "_3c_e9_e_6d_21_84_", + "proto_ver": "0.1", + "door_num": 2, + "night_mode_en": 0, + "fw_ver": "10.10", + "led_brightness": 100, + "data": { + "door1": { + "index": 0, + "status": "open", + "lockup": 0, + "disabled": 0 + }, + "door2": { + "index": 1, + "status": "open", + "lockup": 0, + "disabled": 0 + } + } +} diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..aafd15501ee28e --- /dev/null +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -0,0 +1,147 @@ +# serializer version: 1 +# name: test_number_entities[binary_sensor.door_1_operational_problem] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Door 1 Operational problem', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_1_operational_problem', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_number_entities[binary_sensor.door_1_operational_problem].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_1_operational_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational problem', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_problem', + 'unique_id': '_3c_e9_e_6d_21_84_-door1-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[binary_sensor.door_1_operational_problem].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door1', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': , + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_problem] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Door 2 Operational problem', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_2_operational_problem', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_problem].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_2_operational_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational problem', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_problem', + 'unique_id': '_3c_e9_e_6d_21_84_-door2-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_problem].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door2', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': , + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr new file mode 100644 index 00000000000000..b92b482e23d0f2 --- /dev/null +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_number_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Tailwind iQ3 Identify', + }), + 'context': , + 'entity_id': 'button.tailwind_iq3_identify', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_number_entities.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.tailwind_iq3_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': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-identify', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:e9:0e:6d:21:84', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Tailwind iQ3', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_config_flow.ambr b/tests/components/tailwind/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000000..5c01f35e09c44e --- /dev/null +++ b/tests/components/tailwind/snapshots/test_config_flow.ambr @@ -0,0 +1,85 @@ +# serializer version: 1 +# name: test_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': '3c:e9:0e:6d:21:84', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'tailwind', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'disabled_by': None, + 'domain': 'tailwind', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Tailwind iQ3', + 'unique_id': '3c:e9:0e:6d:21:84', + 'version': 1, + }), + 'title': 'Tailwind iQ3', + 'type': , + 'version': 1, + }) +# --- +# name: test_zeroconf_flow + FlowResultSnapshot({ + 'context': dict({ + 'configuration_url': 'https://web.gotailwind.com/client/integration/local-control-key', + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Tailwind iQ3', + }), + 'unique_id': '3c:e9:0e:6d:21:84', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'tailwind', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'disabled_by': None, + 'domain': 'tailwind', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Tailwind iQ3', + 'unique_id': '3c:e9:0e:6d:21:84', + 'version': 1, + }), + 'title': 'Tailwind iQ3', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr new file mode 100644 index 00000000000000..e5d6306778f646 --- /dev/null +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -0,0 +1,147 @@ +# serializer version: 1 +# name: test_cover_entities[cover.door_1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Door 1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.door_1', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entities[cover.door_1].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.door_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-door1', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities[cover.door_1].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door1', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': , + }) +# --- +# name: test_cover_entities[cover.door_2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Door 2', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.door_2', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entities[cover.door_2].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.door_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-door2', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities[cover.door_2].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door2', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': , + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_diagnostics.ambr b/tests/components/tailwind/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..1ddfe08a4e3fcd --- /dev/null +++ b/tests/components/tailwind/snapshots/test_diagnostics.ambr @@ -0,0 +1,28 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'door1': dict({ + 'disabled': 0, + 'door_id': 'door1', + 'index': 0, + 'lockup': 0, + 'status': 'open', + }), + 'door2': dict({ + 'disabled': 0, + 'door_id': 'door2', + 'index': 1, + 'lockup': 0, + 'status': 'open', + }), + }), + 'dev_id': '_3c_e9_e_6d_21_84_', + 'door_num': 2, + 'fw_ver': '10.10', + 'led_brightness': 100, + 'night_mode_en': 0, + 'product': 'iQ3', + 'proto_ver': '0.1', + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr new file mode 100644 index 00000000000000..1d1444461ff279 --- /dev/null +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -0,0 +1,87 @@ +# serializer version: 1 +# name: test_number_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tailwind iQ3 Status LED brightness', + 'icon': 'mdi:led-on', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.tailwind_iq3_status_led_brightness', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_entities.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.tailwind_iq3_status_led_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:led-on', + 'original_name': 'Status LED brightness', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness', + 'unique_id': '_3c_e9_e_6d_21_84_-brightness', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:e9:0e:6d:21:84', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Tailwind iQ3', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/test_binary_sensor.py b/tests/components/tailwind/test_binary_sensor.py new file mode 100644 index 00000000000000..a2bb574986cde1 --- /dev/null +++ b/tests/components/tailwind/test_binary_sensor.py @@ -0,0 +1,35 @@ +"""Tests for binary sensor entities provided by the Tailwind integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize( + "entity_id", + [ + "binary_sensor.door_1_operational_problem", + "binary_sensor.door_2_operational_problem", + ], +) +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test binary sensor entities provided by the Tailwind integration.""" + assert (state := hass.states.get(entity_id)) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(state.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 diff --git a/tests/components/tailwind/test_button.py b/tests/components/tailwind/test_button.py new file mode 100644 index 00000000000000..a0128d5f4985ab --- /dev/null +++ b/tests/components/tailwind/test_button.py @@ -0,0 +1,65 @@ +"""Tests for button entities provided by the Tailwind integration.""" +from unittest.mock import MagicMock + +from gotailwind import TailwindError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2023-12-17 15:25:00"), +] + + +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_tailwind: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test button entities provided by the Tailwind integration.""" + assert (state := hass.states.get("button.tailwind_iq3_identify")) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(state.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 + + assert len(mock_tailwind.identify.mock_calls) == 0 + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + assert len(mock_tailwind.identify.mock_calls) == 1 + mock_tailwind.identify.assert_called_with() + + assert (state := hass.states.get(state.entity_id)) + assert state.state == "2023-12-17T15:25:00+00:00" + + # Test error handling + mock_tailwind.identify.side_effect = TailwindError("Some error") + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py new file mode 100644 index 00000000000000..6d35ccea85ab1b --- /dev/null +++ b/tests/components/tailwind/test_config_flow.py @@ -0,0 +1,428 @@ +"""Configuration flow tests for the Tailwind integration.""" +from ipaddress import ip_address +from unittest.mock import MagicMock + +from gotailwind import ( + TailwindAuthenticationError, + TailwindConnectionError, + TailwindUnsupportedFirmwareVersionError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import zeroconf +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.config_entries import ( + SOURCE_DHCP, + SOURCE_REAUTH, + SOURCE_USER, + SOURCE_ZEROCONF, +) +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_user_flow( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the full happy path user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {CONF_HOST: "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show user form on a connection error.""" + mock_tailwind.status.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == expected_error + + mock_tailwind.status.side_effect = None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.2", + CONF_TOKEN: "123456", + }, + ) + assert result2.get("type") == FlowResultType.CREATE_ENTRY + + +async def test_user_flow_unsupported_firmware_version( + hass: HomeAssistant, mock_tailwind: MagicMock +) -> None: + """Test configuration flow aborts when the firmware version is not supported.""" + mock_tailwind.status.side_effect = TailwindUnsupportedFirmwareVersionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "unsupported_firmware" + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test configuration flow aborts when the device is already configured. + + Also, ensures the existing config entry is updated with the new host. + """ + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_config_entry.data[CONF_TOKEN] == "987654" + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_zeroconf_flow( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the zeroconf happy flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties={ + "device_id": "_3c_e9_e_6d_21_84_", + "product": "iQ3", + "SW ver": "10.10", + "vendor": "tailwind", + }, + type="mock_type", + ), + ) + + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("type") == FlowResultType.FORM + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0].get("flow_id") == result["flow_id"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_TOKEN: "987654"} + ) + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +@pytest.mark.parametrize( + ("properties", "expected_reason"), + [ + ({"SW ver": "10.10"}, "no_device_id"), + ({"device_id": "_3c_e9_e_6d_21_84_", "SW ver": "0.0"}, "unsupported_firmware"), + ], +) +async def test_zeroconf_flow_abort_incompatible_properties( + hass: HomeAssistant, properties: dict[str, str], expected_reason: str +) -> None: + """Test the zeroconf aborts when it advertises incompatible data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties=properties, + type="mock_type", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == expected_reason + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {"base": "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show form on a error.""" + mock_tailwind.status.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties={ + "device_id": "_3c_e9_e_6d_21_84_", + "product": "iQ3", + "SW ver": "10.10", + "vendor": "tailwind", + }, + type="mock_type", + ), + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "zeroconf_confirm" + assert result2.get("errors") == expected_error + + mock_tailwind.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + assert result3.get("type") == FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_zeroconf_flow_not_discovered_again( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the zeroconf doesn't re-discover an existing device. + + Also, ensures the existing config entry is updated with the new host. + """ + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties={ + "device_id": "_3c_e9_e_6d_21_84_", + "product": "iQ3", + "SW ver": "10.10", + "vendor": "tailwind", + }, + type="mock_type", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_TOKEN] == "123456" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "987654"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + assert mock_config_entry.data[CONF_TOKEN] == "987654" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {"base": "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show form on a error.""" + mock_config_entry.add_to_hass(hass) + mock_tailwind.status.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == expected_error + + mock_tailwind.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result3.get("type") == FlowResultType.ABORT + assert result3.get("reason") == "reauth_successful" + + +async def test_dhcp_discovery_updates_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery updates config entries.""" + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="tailwind-3ce90e6d2184.local.", + ip="127.0.0.1", + macaddress="3c:e9:0e:6d:21:84", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_dhcp_discovery_ignores_unknown(hass: HomeAssistant) -> None: + """Test DHCP discovery is only used for updates. + + Anything else will just abort the flow. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="tailwind-3ce90e6d2184.local.", + ip="127.0.0.1", + macaddress="3c:e9:0e:6d:21:84", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "unknown" diff --git a/tests/components/tailwind/test_cover.py b/tests/components/tailwind/test_cover.py new file mode 100644 index 00000000000000..9620d6149b7cf8 --- /dev/null +++ b/tests/components/tailwind/test_cover.py @@ -0,0 +1,170 @@ +"""Tests for cover entities provided by the Tailwind integration.""" +from unittest.mock import ANY, MagicMock + +from gotailwind import ( + TailwindDoorDisabledError, + TailwindDoorLockedOutError, + TailwindDoorOperationCommand, + TailwindError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize( + "entity_id", + [ + "cover.door_1", + "cover.door_2", + ], +) +async def test_cover_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test cover entities provided by the Tailwind integration.""" + assert (state := hass.states.get(entity_id)) + assert state == snapshot + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot + + +async def test_cover_operations( + hass: HomeAssistant, + mock_tailwind: MagicMock, +) -> None: + """Test operating the doors.""" + assert len(mock_tailwind.operate.mock_calls) == 0 + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + mock_tailwind.operate.assert_called_with( + door=ANY, operation=TailwindDoorOperationCommand.OPEN + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + mock_tailwind.operate.assert_called_with( + door=ANY, operation=TailwindDoorOperationCommand.CLOSE + ) + + # Test door disabled error handling + mock_tailwind.operate.side_effect = TailwindDoorDisabledError("Door disabled") + + with pytest.raises(HomeAssistantError, match="Door disabled") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_disabled" + + with pytest.raises(HomeAssistantError, match="Door disabled") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_disabled" + + # Test door locked out error handling + mock_tailwind.operate.side_effect = TailwindDoorLockedOutError("Door locked out") + + with pytest.raises(HomeAssistantError, match="Door locked out") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_locked_out" + + with pytest.raises(HomeAssistantError, match="Door locked out") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_locked_out" + + # Test door error handling + mock_tailwind.operate.side_effect = TailwindError("Some error") + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" diff --git a/tests/components/tailwind/test_diagnostics.py b/tests/components/tailwind/test_diagnostics.py new file mode 100644 index 00000000000000..3151d323bcefdb --- /dev/null +++ b/tests/components/tailwind/test_diagnostics.py @@ -0,0 +1,22 @@ +"""Tests for diagnostics provided by the Tailwind integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/tailwind/test_init.py b/tests/components/tailwind/test_init.py new file mode 100644 index 00000000000000..fb61d15500871b --- /dev/null +++ b/tests/components/tailwind/test_init.py @@ -0,0 +1,73 @@ +"""Integration tests for the Tailwind integration.""" +from unittest.mock import MagicMock + +from gotailwind import TailwindAuthenticationError, TailwindConnectionError + +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test the Tailwind configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_tailwind.status.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test the Tailwind configuration entry not ready.""" + mock_tailwind.status.side_effect = TailwindConnectionError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_tailwind.status.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_authentication_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test trigger reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + mock_tailwind.status.side_effect = TailwindAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/tailwind/test_number.py b/tests/components/tailwind/test_number.py new file mode 100644 index 00000000000000..e16c940b85d7bd --- /dev/null +++ b/tests/components/tailwind/test_number.py @@ -0,0 +1,66 @@ +"""Tests for number entities provided by the Tailwind integration.""" +from unittest.mock import MagicMock + +from gotailwind import TailwindError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import number +from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_tailwind: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test number entities provided by the Tailwind integration.""" + assert (state := hass.states.get("number.tailwind_iq3_status_led_brightness")) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(state.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 + + assert len(mock_tailwind.status_led.mock_calls) == 0 + await hass.services.async_call( + number.DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_VALUE: 42, + }, + blocking=True, + ) + + assert len(mock_tailwind.status_led.mock_calls) == 1 + mock_tailwind.status_led.assert_called_with(brightness=42) + + # Test error handling + mock_tailwind.status_led.side_effect = TailwindError("Some error") + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + number.DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_VALUE: 42, + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..f52cb3a88a58a6 --- /dev/null +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + '3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8': dict({ + 'diesel': 1.659, + 'e10': 1.659, + 'e5': 1.719, + 'status': 'open', + }), + }), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'fuel_types': list([ + 'e5', + ]), + 'location': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'name': 'Home', + 'radius': 2.0, + 'stations': list([ + '3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8', + ]), + }), + 'disabled_by': None, + 'domain': 'tankerkoenig', + 'entry_id': '8036b4412f2fae6bb9dbab7fe8e37f87', + 'minor_version': 1, + 'options': dict({ + 'show_on_map': True, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/tankerkoenig/test_diagnostics.py b/tests/components/tankerkoenig/test_diagnostics.py new file mode 100644 index 00000000000000..59f273683a2691 --- /dev/null +++ b/tests/components/tankerkoenig/test_diagnostics.py @@ -0,0 +1,103 @@ +"""Tests for the Tankerkoening integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.tankerkoenig.const import ( + CONF_FUEL_TYPES, + CONF_STATIONS, + DOMAIN, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, + CONF_SHOW_ON_MAP, +) +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 + +MOCK_USER_DATA = { + CONF_NAME: "Home", + CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", + CONF_FUEL_TYPES: ["e5"], + CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, + CONF_RADIUS: 2.0, + CONF_STATIONS: [ + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + ], +} +MOCK_OPTIONS = { + CONF_SHOW_ON_MAP: True, +} + +MOCK_STATION_DATA = { + "ok": True, + "station": { + "id": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + "name": "Station ABC", + "brand": "Station", + "street": "Somewhere Street", + "houseNumber": "1", + "postCode": "01234", + "place": "Somewhere", + "openingTimes": [], + "overrides": [], + "wholeDay": True, + "isOpen": True, + "e5": 1.719, + "e10": 1.659, + "diesel": 1.659, + "lat": 51.1, + "lng": 13.1, + "state": "xxXX", + }, +} +MOCK_STATION_PRICES = { + "ok": True, + "prices": { + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8": { + "status": "open", + "e5": 1.719, + "e10": 1.659, + "diesel": 1.659, + }, + }, +} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + with patch( + "homeassistant.components.tankerkoenig.coordinator.pytankerkoenig.getStationData", + return_value=MOCK_STATION_DATA, + ), patch( + "homeassistant.components.tankerkoenig.coordinator.pytankerkoenig.getPriceList", + return_value=MOCK_STATION_PRICES, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_USER_DATA, + options=MOCK_OPTIONS, + unique_id="mock.tankerkoenig", + entry_id="8036b4412f2fae6bb9dbab7fe8e37f87", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == snapshot diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 2bfb4a9d5e2436..d5f1e4d7101709 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -31,6 +31,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -313,6 +315,21 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + config["swn"][0] = "Test" + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.BINARY_SENSOR, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -323,6 +340,18 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.BINARY_SENSOR, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability when deep sleep is enabled.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + config["swn"][0] = "Test" + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.BINARY_SENSOR, config + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index a184f650faea81..1f414cb4e5abb3 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -4,6 +4,7 @@ from unittest.mock import ANY from hatasmota.const import ( + CONF_DEEP_SLEEP, CONF_MAC, CONF_OFFLINE, CONF_ONLINE, @@ -188,6 +189,76 @@ async def help_test_availability_when_connection_lost( assert state.state != STATE_UNAVAILABLE +async def help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + domain, + config, + sensor_config=None, + object_id="tasmota_test", +): + """Test availability after MQTT disconnection when deep sleep is enabled. + + This is a test helper for the TasmotaAvailability mixin. + """ + config[CONF_DEEP_SLEEP] = 1 + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + # Device online + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + # Disconnected from MQTT server -> state changed to unavailable + mqtt_mock.connected = False + await hass.async_add_executor_job(mqtt_client_mock.on_disconnect, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state == STATE_UNAVAILABLE + + # Reconnected to MQTT server -> state no longer unavailable + mqtt_mock.connected = True + await hass.async_add_executor_job(mqtt_client_mock.on_connect, None, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + # Receive LWT again + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_online(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_offline(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async def help_test_availability( hass, mqtt_mock, @@ -236,6 +307,55 @@ async def help_test_availability( assert state.state == STATE_UNAVAILABLE +async def help_test_deep_sleep_availability( + hass, + mqtt_mock, + domain, + config, + sensor_config=None, + object_id="tasmota_test", +): + """Test availability when deep sleep is enabled. + + This is a test helper for the TasmotaAvailability mixin. + """ + config[CONF_DEEP_SLEEP] = 1 + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_online(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_offline(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async def help_test_availability_discovery_update( hass, mqtt_mock, diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index e2bdc8b2ca728c..26f8dee4a9df40 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -22,6 +22,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -33,16 +35,16 @@ from tests.typing import MqttMockHAClient, MqttMockPahoClient COVER_SUPPORT = ( - cover.SUPPORT_OPEN - | cover.SUPPORT_CLOSE - | cover.SUPPORT_STOP - | cover.SUPPORT_SET_POSITION + cover.CoverEntityFeature.OPEN + | cover.CoverEntityFeature.CLOSE + | cover.CoverEntityFeature.STOP + | cover.CoverEntityFeature.SET_POSITION ) TILT_SUPPORT = ( - cover.SUPPORT_OPEN_TILT - | cover.SUPPORT_CLOSE_TILT - | cover.SUPPORT_STOP_TILT - | cover.SUPPORT_SET_TILT_POSITION + cover.CoverEntityFeature.OPEN_TILT + | cover.CoverEntityFeature.CLOSE_TILT + | cover.CoverEntityFeature.STOP_TILT + | cover.CoverEntityFeature.SET_TILT_POSITION ) @@ -663,6 +665,27 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Test" + config["rl"][0] = 3 + config["rl"][1] = 3 + await help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + Platform.COVER, + config, + object_id="test_cover_1", + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -676,6 +699,19 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Test" + config["rl"][0] = 3 + config["rl"][1] = 3 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 2a50e2d43b57c4..727fddc9bd387b 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -22,6 +22,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -58,7 +60,7 @@ async def test_controlling_state_via_mqtt( state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF assert state.attributes["percentage"] is None - assert state.attributes["supported_features"] == fan.SUPPORT_SET_SPEED + assert state.attributes["supported_features"] == fan.FanEntityFeature.SET_SPEED assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":1}') @@ -232,6 +234,20 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["if"] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -243,6 +259,17 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["if"] = 1 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 27b7bd1a82a643..50f11fb7757210 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -22,6 +22,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -1669,6 +1671,21 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 2 + config["lt_st"] = 1 # 1 channel light (Dimmer) + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.LIGHT, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -1679,6 +1696,16 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.LIGHT, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 2 + config["lt_st"] = 1 # 1 channel light (Dimmer) + await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.LIGHT, config) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 2f50a84ffdd1a1..dc4820779a6671 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -28,6 +28,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -1222,6 +1224,26 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + Platform.SENSOR, + config, + sensor_config, + "tasmota_dht11_temperature", + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -1238,6 +1260,22 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_deep_sleep_availability( + hass, + mqtt_mock, + Platform.SENSOR, + config, + sensor_config, + "tasmota_dht11_temperature", + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index 54d94b46fe89d1..1a16f372fc98b3 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -20,6 +20,8 @@ help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -158,6 +160,20 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.SWITCH, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -167,6 +183,15 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.SWITCH, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.SWITCH, config) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tedee/__init__.py b/tests/components/tedee/__init__.py new file mode 100644 index 00000000000000..a72b1fbdd6a13e --- /dev/null +++ b/tests/components/tedee/__init__.py @@ -0,0 +1 @@ +"""Add tests for Tedee components.""" diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py new file mode 100644 index 00000000000000..21fb4047ab3e9b --- /dev/null +++ b/tests/components/tedee/conftest.py @@ -0,0 +1,81 @@ +"""Fixtures for Tedee integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from pytedee_async.bridge import TedeeBridge +from pytedee_async.lock import TedeeLock +import pytest + +from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My Tedee", + domain=DOMAIN, + data={ + CONF_LOCAL_ACCESS_TOKEN: "api_token", + CONF_HOST: "192.168.1.42", + }, + unique_id="0000-0000", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.tedee.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_tedee(request) -> Generator[MagicMock, None, None]: + """Return a mocked Tedee client.""" + with patch( + "homeassistant.components.tedee.coordinator.TedeeClient", autospec=True + ) as tedee_mock, patch( + "homeassistant.components.tedee.config_flow.TedeeClient", + new=tedee_mock, + ): + tedee = tedee_mock.return_value + + tedee.get_locks.return_value = None + tedee.sync.return_value = None + tedee.get_bridges.return_value = [ + TedeeBridge(1234, "0000-0000", "Bridge-AB1C"), + TedeeBridge(5678, "9999-9999", "Bridge-CD2E"), + ] + tedee.get_local_bridge.return_value = TedeeBridge(0, "0000-0000", "Bridge-AB1C") + + tedee.parse_webhook_message.return_value = None + + locks_json = json.loads(load_fixture("locks.json", DOMAIN)) + + lock_list = [TedeeLock(**lock) for lock in locks_json] + tedee.locks_dict = {lock.lock_id: lock for lock in lock_list} + + yield tedee + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> MockConfigEntry: + """Set up the Tedee integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/tedee/fixtures/locks.json b/tests/components/tedee/fixtures/locks.json new file mode 100644 index 00000000000000..6a8eb77d7ee8c2 --- /dev/null +++ b/tests/components/tedee/fixtures/locks.json @@ -0,0 +1,26 @@ +[ + { + "lock_name": "Lock-1A2B", + "lock_id": 12345, + "lock_type": 2, + "state": 2, + "battery_level": 70, + "is_connected": true, + "is_charging": false, + "state_change_result": 0, + "is_enabled_pullspring": 1, + "duration_pullspring": 2 + }, + { + "lock_name": "Lock-2C3D", + "lock_id": 98765, + "lock_type": 4, + "state": 2, + "battery_level": 70, + "is_connected": true, + "is_charging": false, + "state_change_result": 0, + "is_enabled_pullspring": 0, + "duration_pullspring": 0 + } +] diff --git a/tests/components/tedee/snapshots/test_binary_sensor.ambr b/tests/components/tedee/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..16be8aafd0e724 --- /dev/null +++ b/tests/components/tedee/snapshots/test_binary_sensor.ambr @@ -0,0 +1,131 @@ +# serializer version: 1 +# name: test_binary_sensors[entry-charging] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_1a2b_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345-charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[entry-pullspring_enabled] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_1a2b_pullspring_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pullspring enabled', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pullspring_enabled', + 'unique_id': '12345-pullspring_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[entry-semi_locked] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_1a2b_semi_locked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Semi locked', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'semi_locked', + 'unique_id': '12345-semi_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[state-charging] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Lock-1A2B Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_charging', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[state-pullspring_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B Pullspring enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_pullspring_enabled', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[state-semi_locked] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B Semi locked', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_semi_locked', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tedee/snapshots/test_diagnostics.ambr b/tests/components/tedee/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..401c519c215419 --- /dev/null +++ b/tests/components/tedee/snapshots/test_diagnostics.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + '0': dict({ + 'battery_level': 70, + 'duration_pullspring': 2, + 'is_charging': False, + 'is_connected': True, + 'is_enabled_pullspring': 1, + 'lock_id': '**REDACTED**', + 'lock_name': 'Lock-1A2B', + 'lock_type': 2, + 'state': 2, + 'state_change_result': 0, + }), + '1': dict({ + 'battery_level': 70, + 'duration_pullspring': 0, + 'is_charging': False, + 'is_connected': True, + 'is_enabled_pullspring': 0, + 'lock_id': '**REDACTED**', + 'lock_name': 'Lock-2C3D', + 'lock_type': 4, + 'state': 2, + 'state_change_result': 0, + }), + }) +# --- diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr new file mode 100644 index 00000000000000..e10a9f298bbae9 --- /dev/null +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_bridge_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tedee', + '0000-0000', + ), + }), + 'is_new': False, + 'manufacturer': 'Tedee', + 'model': 'Bridge', + 'name': 'Bridge-AB1C', + 'name_by_user': None, + 'serial_number': '0000-0000', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr new file mode 100644 index 00000000000000..dd0eab46c90855 --- /dev/null +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_lock + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.lock_1a2b', + 'last_changed': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.lock_1a2b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tedee', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Tedee', + 'model': 'Tedee PRO', + 'name': 'Lock-1A2B', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_lock_without_pullspring + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-2C3D', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.lock_2c3d', + 'last_changed': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_without_pullspring.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.lock_2c3d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_without_pullspring.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tedee', + '98765', + ), + }), + 'is_new': False, + 'manufacturer': 'Tedee', + 'model': 'Tedee GO', + 'name': 'Lock-2C3D', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- diff --git a/tests/components/tedee/snapshots/test_sensor.ambr b/tests/components/tedee/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..a74ee38bff0719 --- /dev/null +++ b/tests/components/tedee/snapshots/test_sensor.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_sensors[entry-battery] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lock_1a2b_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345-battery_sensor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[entry-pullspring_duration] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lock_1a2b_pullspring_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-lock-open', + 'original_name': 'Pullspring duration', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pullspring_duration', + 'unique_id': '12345-pullspring_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[state-battery] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Lock-1A2B Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.lock_1a2b_battery', + 'last_changed': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensors[state-pullspring_duration] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Lock-1A2B Pullspring duration', + 'icon': 'mdi:timer-lock-open', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lock_1a2b_pullspring_duration', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/tedee/test_binary_sensor.py b/tests/components/tedee/test_binary_sensor.py new file mode 100644 index 00000000000000..bdb66c9c0a9627 --- /dev/null +++ b/tests/components/tedee/test_binary_sensor.py @@ -0,0 +1,34 @@ +"""Tests for the Tedee Binary Sensors.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + +BINARY_SENSORS = ( + "charging", + "semi_locked", + "pullspring_enabled", +) + + +async def test_binary_sensors( + hass: HomeAssistant, + mock_tedee: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test tedee battery charging sensor.""" + for key in BINARY_SENSORS: + state = hass.states.get(f"binary_sensor.lock_1a2b_{key}") + assert state + assert state == snapshot(name=f"state-{key}") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot(name=f"entry-{key}") diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py new file mode 100644 index 00000000000000..4feb9bb8ca5aa0 --- /dev/null +++ b/tests/components/tedee/test_config_flow.py @@ -0,0 +1,180 @@ +"""Test the Tedee config flow.""" +from unittest.mock import MagicMock + +from pytedee_async import TedeeClientException, TedeeLocalAuthException +import pytest + +from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +FLOW_UNIQUE_ID = "112233445566778899" +LOCAL_ACCESS_TOKEN = "api_token" + + +async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None: + """Test config flow with one bridge.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + } + + +async def test_flow_already_configured( + hass: HomeAssistant, + mock_tedee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow aborts when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + }, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TedeeClientException("boom."), {CONF_HOST: "invalid_host"}), + ( + TedeeLocalAuthException("boom."), + {CONF_LOCAL_ACCESS_TOKEN: "invalid_api_key"}, + ), + ], +) +async def test_config_flow_errors( + hass: HomeAssistant, + mock_tedee: MagicMock, + side_effect: Exception, + error: dict[str, str], +) -> None: + """Test the config flow errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + + mock_tedee.get_local_bridge.side_effect = side_effect + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.42", + CONF_LOCAL_ACCESS_TOKEN: "wrong_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + assert len(mock_tedee.get_local_bridge.mock_calls) == 1 + + +async def test_reauth_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> None: + """Test that the reauth flow works.""" + + mock_config_entry.add_to_hass(hass) + + reauth_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data={ + CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, + CONF_HOST: "192.168.1.42", + }, + ) + + result = await hass.config_entries.flow.async_configure( + reauth_result["flow_id"], + { + CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, + }, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TedeeClientException("boom."), {CONF_HOST: "invalid_host"}), + ( + TedeeLocalAuthException("boom."), + {CONF_LOCAL_ACCESS_TOKEN: "invalid_api_key"}, + ), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + side_effect: Exception, + error: dict[str, str], +) -> None: + """Test that the reauth flow errors.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data={ + CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, + CONF_HOST: "192.168.1.42", + }, + ) + + mock_tedee.get_local_bridge.side_effect = side_effect + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + assert len(mock_tedee.get_local_bridge.mock_calls) == 1 diff --git a/tests/components/tedee/test_diagnostics.py b/tests/components/tedee/test_diagnostics.py new file mode 100644 index 00000000000000..9a31e153b6c408 --- /dev/null +++ b/tests/components/tedee/test_diagnostics.py @@ -0,0 +1,21 @@ +"""Tests for the diagnostics data provided by the Tedee integration.""" +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.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py new file mode 100644 index 00000000000000..ca64c01a9830cf --- /dev/null +++ b/tests/components/tedee/test_init.py @@ -0,0 +1,69 @@ +"""Test initialization of tedee.""" +from unittest.mock import MagicMock + +from pytedee_async.exception import TedeeAuthException, TedeeClientException +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "side_effect", [TedeeClientException(""), TedeeAuthException("")] +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + side_effect: Exception, +) -> None: + """Test the Tedee configuration entry not ready.""" + mock_tedee.get_locks.side_effect = side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_tedee.get_locks.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_bridge_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure the bridge device is registered.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + {(mock_config_entry.domain, mock_tedee.get_local_bridge.return_value.serial)} + ) + assert device + assert device == snapshot diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py new file mode 100644 index 00000000000000..995d036fba7b89 --- /dev/null +++ b/tests/components/tedee/test_lock.py @@ -0,0 +1,209 @@ +"""Tests for tedee lock.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pytedee_async.exception import ( + TedeeClientException, + TedeeDataUpdateException, + TedeeLocalAuthException, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, + STATE_LOCKING, + STATE_UNLOCKING, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_lock( + hass: HomeAssistant, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the tedee lock.""" + mock_tedee.lock.return_value = None + mock_tedee.unlock.return_value = None + mock_tedee.open.return_value = None + + state = hass.states.get("lock.lock_1a2b") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + assert entry.device_id + + device = device_registry.async_get(entry.device_id) + assert device == snapshot + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + assert len(mock_tedee.lock.mock_calls) == 1 + mock_tedee.lock.assert_called_once_with(12345) + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_LOCKING + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + assert len(mock_tedee.unlock.mock_calls) == 1 + mock_tedee.unlock.assert_called_once_with(12345) + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_UNLOCKING + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + assert len(mock_tedee.open.mock_calls) == 1 + mock_tedee.open.assert_called_once_with(12345) + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_UNLOCKING + + +async def test_lock_without_pullspring( + hass: HomeAssistant, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the tedee lock without pullspring.""" + mock_tedee.lock.return_value = None + mock_tedee.unlock.return_value = None + mock_tedee.open.return_value = None + + state = hass.states.get("lock.lock_2c3d") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + assert entry.device_id + device = device_registry.async_get(entry.device_id) + assert device + assert device == snapshot + + with pytest.raises( + HomeAssistantError, + match="Entity lock.lock_2c3d does not support this service.", + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + { + ATTR_ENTITY_ID: "lock.lock_2c3d", + }, + blocking=True, + ) + + assert len(mock_tedee.open.mock_calls) == 0 + + +async def test_lock_errors( + hass: HomeAssistant, + mock_tedee: MagicMock, +) -> None: + """Test event errors.""" + mock_tedee.lock.side_effect = TedeeClientException("Boom") + with pytest.raises(HomeAssistantError, match="Failed to lock the door. Lock 12345"): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + mock_tedee.unlock.side_effect = TedeeClientException("Boom") + with pytest.raises( + HomeAssistantError, match="Failed to unlock the door. Lock 12345" + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + mock_tedee.open.side_effect = TedeeClientException("Boom") + with pytest.raises( + HomeAssistantError, match="Failed to unlatch the door. Lock 12345" + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + "side_effect", + [ + TedeeClientException("Boom"), + TedeeLocalAuthException("Boom"), + TimeoutError, + TedeeDataUpdateException("Boom"), + ], +) +async def test_update_failed( + hass: HomeAssistant, + mock_tedee: MagicMock, + freezer: FrozenDateTimeFactory, + side_effect: Exception, +) -> None: + """Test update failed.""" + mock_tedee.sync.side_effect = side_effect + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("lock.lock_1a2b") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/tedee/test_sensor.py b/tests/components/tedee/test_sensor.py new file mode 100644 index 00000000000000..95cde20a82ff72 --- /dev/null +++ b/tests/components/tedee/test_sensor.py @@ -0,0 +1,36 @@ +"""Tests for the Tedee Sensors.""" + + +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +SENSORS = ( + "battery", + "pullspring_duration", +) + + +async def test_sensors( + hass: HomeAssistant, + mock_tedee: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test tedee sensors.""" + for key in SENSORS: + state = hass.states.get(f"sensor.lock_1a2b_{key}") + assert state + assert state == snapshot(name=f"state-{key}") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot(name=f"entry-{key}") diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr index 72af2ab1637ab1..0ee7f9671762be 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 dd4fa1d32a53ef..ef2390680b6751 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_button.py b/tests/components/template/test_button.py index bfdb9352767ec5..ece568eee4949c 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -1,6 +1,7 @@ """The tests for the Template button platform.""" import datetime as dt -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant import setup from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS @@ -59,7 +60,9 @@ async def test_missing_required_keys(hass: HomeAssistant) -> None: assert hass.states.async_all("button") == [] -async def test_all_optional_config(hass: HomeAssistant, calls) -> None: +async def test_all_optional_config( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test: including all optional templates is ok.""" with assert_setup_component(1, "template"): assert await setup.async_setup_component( @@ -98,14 +101,13 @@ async def test_all_optional_config(hass: HomeAssistant, calls) -> None: ) now = dt.datetime.now(dt.UTC) - - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, + blocking=True, + ) assert len(calls) == 1 assert calls[0].data["caller"] == _TEST_OPTIONS_BUTTON diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index f4cfe90b9f0344..b95a68afd858a2 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 fefad59aa08735..88f0fc366a3601 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' from integration '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 f9b0bddddcf210..ccdafebd8bb8f6 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -12,6 +12,7 @@ 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 f807b185c45ef7..ec830d4daf6a4c 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -7,6 +7,9 @@ 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_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_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( @@ -791,6 +858,48 @@ async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None assert state.attributes["entity_picture"] == "/local/light.png" +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "light_config", + [ + { + "test_template_light": { + **OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + } + }, + ], +) +async def test_legacy_color_action_no_template( + hass, + setup_light, + calls, +): + """Test setting 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_color" + 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", @@ -803,12 +912,12 @@ async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None }, ], ) -async def test_color_action_no_template( +async def test_hs_color_action_no_template( hass: HomeAssistant, setup_light, calls, ) -> None: - """Test setting color with optimistic template.""" + """Test setting hs color with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None @@ -820,7 +929,7 @@ async def test_color_action_no_template( ) assert len(calls) == 1 - assert calls[-1].data["action"] == "set_color" + 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 @@ -833,6 +942,144 @@ 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_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,21 +1092,62 @@ 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, +): + """Test the template for the color.""" + light_config = { + "test_template_light": { + **OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + "color_template": color_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_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 }}", - "color_template": color_template, + "hs_template": hs_template, } } await async_setup_light(hass, count, light_config) @@ -871,6 +1159,136 @@ async def test_color_template( 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,18 +1396,108 @@ 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_HS_COLOR: (10, 20)}, + {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, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_HS_COLOR: (10, 20)}, + blocking=True, + ) + + 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_trigger.py b/tests/components/template/test_trigger.py index 84fdadfec0d97c..af010c57e2efa0 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -1,8 +1,8 @@ """The tests for the Template automation.""" from datetime import timedelta from unittest import mock -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import homeassistant.components.automation as automation @@ -803,56 +803,56 @@ async def test_invalid_for_template_1(hass: HomeAssistant, start_ha, calls) -> N assert mock_logger.error.called -async def test_if_fires_on_time_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_time_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing on time changes.""" start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ utcnow().minute % 2 == 0 }}", - }, - "action": {"service": "test.automation"}, - } - }, - ) - await hass.async_block_till_done() - assert len(calls) == 0 + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ utcnow().minute % 2 == 0 }}", + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 # Trigger once (match template) first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (match template) second_time = start_time.replace(minute=4, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=second_time): - async_fire_time_changed(hass, second_time) - await hass.async_block_till_done() + freezer.move_to(second_time) + async_fire_time_changed(hass, second_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (do not match template) third_time = start_time.replace(minute=5, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=third_time): - async_fire_time_changed(hass, third_time) - await hass.async_block_till_done() + freezer.move_to(third_time) + async_fire_time_changed(hass, third_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (match template) forth_time = start_time.replace(minute=8, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=forth_time): - async_fire_time_changed(hass, forth_time) - await hass.async_block_till_done() + freezer.move_to(forth_time) + async_fire_time_changed(hass, forth_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 524f9c41aeb8a9..36071c746da764 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -18,7 +18,8 @@ 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/tessie/__init__.py b/tests/components/tessie/__init__.py new file mode 100644 index 00000000000000..df17fe027d9e80 --- /dev/null +++ b/tests/components/tessie/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tessie integration.""" diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py new file mode 100644 index 00000000000000..ae80526e5d9c10 --- /dev/null +++ b/tests/components/tessie/common.py @@ -0,0 +1,62 @@ +"""Tessie common helpers for tests.""" + +from http import HTTPStatus +from unittest.mock import patch + +from aiohttp import ClientConnectionError, ClientResponseError +from aiohttp.client import RequestInfo + +from homeassistant.components.tessie.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + +TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN) +TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN) +TEST_VEHICLE_STATE_ASLEEP = load_json_object_fixture("asleep.json", DOMAIN) +TEST_RESPONSE = {"result": True} +TEST_RESPONSE_ERROR = {"result": False, "reason": "reason why"} + +TEST_CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} +TESSIE_URL = "https://api.tessie.com/" + +TEST_REQUEST_INFO = RequestInfo( + url=TESSIE_URL, method="GET", headers={}, real_url=TESSIE_URL +) + +ERROR_AUTH = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.UNAUTHORIZED +) +ERROR_TIMEOUT = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.REQUEST_TIMEOUT +) +ERROR_UNKNOWN = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.BAD_REQUEST +) +ERROR_VIRTUAL_KEY = ClientResponseError( + request_info=TEST_REQUEST_INFO, + history=None, + status=HTTPStatus.INTERNAL_SERVER_ERROR, +) +ERROR_CONNECTION = ClientConnectionError() + + +async def setup_platform(hass: HomeAssistant, side_effect=None): + """Set up the Tessie platform.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tessie.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + side_effect=side_effect, + ): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py new file mode 100644 index 00000000000000..c7a344d54c56f0 --- /dev/null +++ b/tests/components/tessie/conftest.py @@ -0,0 +1,28 @@ +"""Fixtures for Tessie.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from .common import TEST_STATE_OF_ALL_VEHICLES, TEST_VEHICLE_STATE_ONLINE + + +@pytest.fixture +def mock_get_state(): + """Mock get_state function.""" + with patch( + "homeassistant.components.tessie.coordinator.get_state", + return_value=TEST_VEHICLE_STATE_ONLINE, + ) as mock_get_state: + yield mock_get_state + + +@pytest.fixture +def mock_get_state_of_all_vehicles(): + """Mock get_state_of_all_vehicles function.""" + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_get_state_of_all_vehicles: + yield mock_get_state_of_all_vehicles diff --git a/tests/components/tessie/fixtures/asleep.json b/tests/components/tessie/fixtures/asleep.json new file mode 100644 index 00000000000000..4f78efafcf185f --- /dev/null +++ b/tests/components/tessie/fixtures/asleep.json @@ -0,0 +1 @@ +{ "state": "asleep" } diff --git a/tests/components/tessie/fixtures/online.json b/tests/components/tessie/fixtures/online.json new file mode 100644 index 00000000000000..863e9bca7836fb --- /dev/null +++ b/tests/components/tessie/fixtures/online.json @@ -0,0 +1,276 @@ +{ + "user_id": 234567890, + "vehicle_id": 345678901, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["beef", "c0ffee"], + "state": "online", + "in_service": false, + "id_s": "123456789", + "calendar_enabled": true, + "api_version": 67, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 75, + "battery_range": 263.68, + "charge_amps": 32, + "charge_current_request": 32, + "charge_current_request_max": 32, + "charge_enable_request": true, + "charge_energy_added": 18.47, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 84, + "charge_miles_added_rated": 84, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 30.6, + "charger_actual_current": 32, + "charger_phases": 1, + "charger_pilot_current": 32, + "charger_power": 7, + "charger_voltage": 224, + "charging_state": "Charging", + "conn_charge_cable": "IEC", + "est_battery_range": 324.73, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 263.68, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 30, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "StartAt", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": 1701216000, + "scheduled_charging_start_time_app": 600, + "scheduled_charging_start_time_minutes": 600, + "scheduled_departure_time": 1694899800, + "scheduled_departure_time_minutes": 450, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0.5, + "timestamp": 1701139037461, + "trip_charging": false, + "usable_battery_level": 75, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": true, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": true, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22.5, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 30.4, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 234, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30.5, + "passenger_temp_setting": 22.5, + "remote_heater_control_enabled": false, + "right_temp_direction": 234, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1701139037461, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": 30.2226265, + "active_route_longitude": -97.6236871, + "active_route_traffic_minutes_delay": 0, + "gps_as_of": 1701129612, + "heading": 185, + "latitude": -30.222626, + "longitude": -97.6236871, + "native_latitude": -30.222626, + "native_location_supported": 1, + "native_longitude": -97.6236871, + "native_type": "wgs", + "power": -7, + "shift_state": null, + "speed": null, + "timestamp": 1701139037461 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1701139037461 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1701139037461, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 67, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.38.6 c1f85ddb415f", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,7f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": true, + "media_info": { + "audio_volume": 2.3333, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Playing", + "now_playing_album": "Album", + "now_playing_artist": "Artist", + "now_playing_duration": 60000, + "now_playing_elapsed": 30000, + "now_playing_source": "Spotify", + "now_playing_station": "Playlist", + "now_playing_title": "Song" + }, + "media_state": { + "remote_control_enabled": false + }, + "notifications_supported": true, + "odometer": 5454.495383, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 0, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "", + "version": " " + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 74.564543, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1701139037461, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1701062077, + "tpms_last_seen_pressure_time_fr": 1701062047, + "tpms_last_seen_pressure_time_rl": 1701062077, + "tpms_last_seen_pressure_time_rr": 1701062047, + "tpms_pressure_fl": 2.975, + "tpms_pressure_fr": 2.975, + "tpms_pressure_rl": 2.95, + "tpms_pressure_rr": 2.95, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + }, + "display_name": "Test" +} diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json new file mode 100644 index 00000000000000..e150b9e60e7802 --- /dev/null +++ b/tests/components/tessie/fixtures/vehicles.json @@ -0,0 +1,292 @@ +{ + "results": [ + { + "vin": "VINVINVIN", + "is_active": true, + "is_archived_manually": false, + "last_charge_created_at": null, + "last_charge_updated_at": null, + "last_drive_created_at": null, + "last_drive_updated_at": null, + "last_idle_created_at": null, + "last_idle_updated_at": null, + "last_state": { + "id": 123456789, + "user_id": 234567890, + "vehicle_id": 345678901, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["beef", "c0ffee"], + "state": "online", + "in_service": false, + "id_s": "123456789", + "calendar_enabled": true, + "api_version": 67, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 75, + "battery_range": 263.68, + "charge_amps": 32, + "charge_current_request": 32, + "charge_current_request_max": 32, + "charge_enable_request": true, + "charge_energy_added": 18.47, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 84, + "charge_miles_added_rated": 84, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 30.6, + "charger_actual_current": 32, + "charger_phases": 1, + "charger_pilot_current": 32, + "charger_power": 7, + "charger_voltage": 224, + "charging_state": "Charging", + "conn_charge_cable": "IEC", + "est_battery_range": 324.73, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 263.68, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 30, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "StartAt", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": 1701216000, + "scheduled_charging_start_time_app": 600, + "scheduled_charging_start_time_minutes": 600, + "scheduled_departure_time": 1694899800, + "scheduled_departure_time_minutes": 450, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0.5, + "timestamp": 1701139037461, + "trip_charging": false, + "usable_battery_level": 75, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": true, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": true, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22.5, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 30.4, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 234, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30.5, + "passenger_temp_setting": 22.5, + "remote_heater_control_enabled": false, + "right_temp_direction": 234, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1701139037461, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": 30.2226265, + "active_route_longitude": -97.6236871, + "active_route_traffic_minutes_delay": 0, + "gps_as_of": 1701129612, + "heading": 185, + "latitude": -30.222626, + "longitude": -97.6236871, + "native_latitude": -30.222626, + "native_location_supported": 1, + "native_longitude": -97.6236871, + "native_type": "wgs", + "power": -7, + "shift_state": null, + "speed": null, + "timestamp": 1701139037461 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1701139037461 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1701139037461, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 67, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.38.6 c1f85ddb415f", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,7f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": true, + "media_info": { + "audio_volume": 2.3333, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Stopped", + "now_playing_album": "", + "now_playing_artist": "", + "now_playing_duration": 0, + "now_playing_elapsed": 0, + "now_playing_source": "", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": false + }, + "notifications_supported": true, + "odometer": 5454.495383, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 100, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "available", + "version": "2023.44.30.4" + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 74.564543, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1701139037461, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1701062077, + "tpms_last_seen_pressure_time_fr": 1701062047, + "tpms_last_seen_pressure_time_rl": 1701062077, + "tpms_last_seen_pressure_time_rr": 1701062047, + "tpms_pressure_fl": 2.975, + "tpms_pressure_fr": 2.975, + "tpms_pressure_rl": 2.95, + "tpms_pressure_rr": 2.95, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + }, + "display_name": "Test" + } + } + ] +} diff --git a/tests/components/tessie/snapshots/test_cover.ambr b/tests/components/tessie/snapshots/test_cover.ambr new file mode 100644 index 00000000000000..ae5e95be68d696 --- /dev/null +++ b/tests/components/tessie/snapshots/test_cover.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_covers[cover.test_charge_port_door-open_unlock_charge_port-close_charge_port][cover.test_charge_port_door] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Charge port door', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_charge_port_door', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_covers[cover.test_frunk-open_front_trunk-False][cover.test_frunk] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Frunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_covers[cover.test_trunk-open_close_rear_trunk-open_close_rear_trunk][cover.test_trunk] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Trunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_covers[cover.test_vent_windows-vent_windows-close_windows][cover.test_vent_windows] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Vent windows', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_vent_windows', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr new file mode 100644 index 00000000000000..e4c7f37c4ce93a --- /dev/null +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_media_player_idle + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player_idle.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player_playing + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensors + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/tessie/test_binary_sensors.py b/tests/components/tessie/test_binary_sensors.py new file mode 100644 index 00000000000000..7f1eb1805a2c7a --- /dev/null +++ b/tests/components/tessie/test_binary_sensors.py @@ -0,0 +1,33 @@ +"""Test the Tessie binary sensor platform.""" + +from homeassistant.components.tessie.binary_sensor import DESCRIPTIONS +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + +OFFON = [STATE_OFF, STATE_ON] + + +async def test_binary_sensors(hass: HomeAssistant) -> None: + """Tests that the binary sensor entities are correct.""" + + assert len(hass.states.async_all("binary_sensor")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("binary_sensor")) == len(DESCRIPTIONS) + + state = hass.states.get("binary_sensor.test_battery_heater").state + is_on = state == STATE_ON + assert is_on == TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_heater_on"] + + state = hass.states.get("binary_sensor.test_charging").state + is_on = state == STATE_ON + assert is_on == ( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charging_state"] == "Charging" + ) + + state = hass.states.get("binary_sensor.test_auto_seat_climate_left").state + is_on = state == STATE_ON + assert is_on == TEST_VEHICLE_STATE_ONLINE["climate_state"]["auto_seat_climate_left"] diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py new file mode 100644 index 00000000000000..153171c8b9fd32 --- /dev/null +++ b/tests/components/tessie/test_button.py @@ -0,0 +1,39 @@ +"""Test the Tessie button platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import setup_platform + + +@pytest.mark.parametrize( + ("entity_id", "func"), + [ + ("button.test_wake", "wake"), + ("button.test_flash_lights", "flash_lights"), + ("button.test_honk_horn", "honk"), + ("button.test_homelink", "trigger_homelink"), + ("button.test_keyless_driving", "enable_keyless_driving"), + ("button.test_play_fart", "boombox"), + ], +) +async def test_buttons(hass: HomeAssistant, entity_id, func) -> None: + """Tests that the button entities are correct.""" + + await setup_platform(hass) + + # Test wake button + with patch( + f"homeassistant.components.tessie.button.{func}", + ) as mock_wake: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_wake.assert_called_once() diff --git a/tests/components/tessie/test_climate.py b/tests/components/tessie/test_climate.py new file mode 100644 index 00000000000000..341e47144700cf --- /dev/null +++ b/tests/components/tessie/test_climate.py @@ -0,0 +1,124 @@ +"""Test the Tessie climate platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.components.tessie.const import TessieClimateKeeper +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import ( + ERROR_UNKNOWN, + TEST_RESPONSE, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + + +async def test_climate(hass: HomeAssistant) -> None: + """Tests that the climate entity is correct.""" + + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 1 + + entity_id = "climate.test_climate" + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert ( + state.attributes.get(ATTR_MIN_TEMP) + == TEST_VEHICLE_STATE_ONLINE["climate_state"]["min_avail_temp"] + ) + assert ( + state.attributes.get(ATTR_MAX_TEMP) + == TEST_VEHICLE_STATE_ONLINE["climate_state"]["max_avail_temp"] + ) + + # Test setting climate on + with patch( + "homeassistant.components.tessie.climate.start_climate_preconditioning", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate temp + with patch( + "homeassistant.components.tessie.climate.set_temperature", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate preset + with patch( + "homeassistant.components.tessie.climate.set_climate_keeper_mode", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: TessieClimateKeeper.ON}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate off + with patch( + "homeassistant.components.tessie.climate.stop_climate", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_set.assert_called_once() + + +async def test_errors(hass: HomeAssistant) -> None: + """Tests virtual key error is handled.""" + + await setup_platform(hass) + entity_id = "climate.test_climate" + + # Test setting climate on with unknown error + with patch( + "homeassistant.components.tessie.climate.start_climate_preconditioning", + side_effect=ERROR_UNKNOWN, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert error.from_exception == ERROR_UNKNOWN diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py new file mode 100644 index 00000000000000..7bc3efa24fc6b2 --- /dev/null +++ b/tests/components/tessie/test_config_flow.py @@ -0,0 +1,168 @@ +"""Test the Tessie config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.tessie.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .common import ( + ERROR_AUTH, + ERROR_CONNECTION, + ERROR_UNKNOWN, + TEST_CONFIG, + setup_platform, +) + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: + """Test we get the form.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result1["type"] == FlowResultType.FORM + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Tessie" + assert result2["data"] == TEST_CONFIG + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ERROR_AUTH, {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (ERROR_UNKNOWN, {"base": "unknown"}), + (ERROR_CONNECTION, {"base": "cannot_connect"}), + ], +) +async def test_form_errors( + hass: HomeAssistant, side_effect, error, mock_get_state_of_all_vehicles +) -> None: + """Test errors are handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_get_state_of_all_vehicles.side_effect = side_effect + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + + # Complete the flow + mock_get_state_of_all_vehicles.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: + """Test reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + assert result1["type"] == FlowResultType.FORM + assert result1["step_id"] == "reauth_confirm" + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ERROR_AUTH, {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (ERROR_UNKNOWN, {"base": "unknown"}), + (ERROR_CONNECTION, {"base": "cannot_connect"}), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, mock_get_state_of_all_vehicles, side_effect, error +) -> None: + """Test reauth flows that failscript/.""" + + mock_entry = await setup_platform(hass) + mock_get_state_of_all_vehicles.side_effect = side_effect + + 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=TEST_CONFIG, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + + # Complete the flow + mock_get_state_of_all_vehicles.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert "errors" not in result3 + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py new file mode 100644 index 00000000000000..311222466fdd22 --- /dev/null +++ b/tests/components/tessie/test_coordinator.py @@ -0,0 +1,78 @@ +"""Test the Tessie sensor platform.""" +from datetime import timedelta + +from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .common import ( + ERROR_AUTH, + ERROR_CONNECTION, + ERROR_UNKNOWN, + TEST_VEHICLE_STATE_ASLEEP, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + +from tests.common import async_fire_time_changed + +WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) + + +async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles online vehicles.""" + + mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("binary_sensor.test_status").state == STATE_ON + + +async def test_coordinator_asleep(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles asleep vehicles.""" + + mock_get_state.return_value = TEST_VEHICLE_STATE_ASLEEP + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("binary_sensor.test_status").state == STATE_OFF + + +async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles client errors.""" + + mock_get_state.side_effect = ERROR_UNKNOWN + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE + + +async def test_coordinator_auth(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles timeout errors.""" + + mock_get_state.side_effect = ERROR_AUTH + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + + +async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles connection errors.""" + + mock_get_state.side_effect = ERROR_CONNECTION + await setup_platform(hass) + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py new file mode 100644 index 00000000000000..713108b962a0f3 --- /dev/null +++ b/tests/components/tessie/test_cover.py @@ -0,0 +1,113 @@ +"""Test the Tessie cover platform.""" +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + STATE_CLOSED, + STATE_OPEN, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import ERROR_UNKNOWN, TEST_RESPONSE, TEST_RESPONSE_ERROR, setup_platform + + +@pytest.mark.parametrize( + ("entity_id", "openfunc", "closefunc"), + [ + ("cover.test_vent_windows", "vent_windows", "close_windows"), + ("cover.test_charge_port_door", "open_unlock_charge_port", "close_charge_port"), + ("cover.test_frunk", "open_front_trunk", False), + ("cover.test_trunk", "open_close_rear_trunk", "open_close_rear_trunk"), + ], +) +async def test_covers( + hass: HomeAssistant, + entity_id: str, + openfunc: str, + closefunc: str, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the window cover entity is correct.""" + + await setup_platform(hass) + + assert hass.states.get(entity_id) == snapshot(name=entity_id) + + # Test open windows + if openfunc: + with patch( + f"homeassistant.components.tessie.cover.{openfunc}", + return_value=TEST_RESPONSE, + ) as mock_open: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_open.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OPEN + + # Test close windows + if closefunc: + with patch( + f"homeassistant.components.tessie.cover.{closefunc}", + return_value=TEST_RESPONSE, + ) as mock_close: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_close.assert_called_once() + assert hass.states.get(entity_id).state == STATE_CLOSED + + +async def test_errors(hass: HomeAssistant) -> None: + """Tests errors are handled.""" + + await setup_platform(hass) + entity_id = "cover.test_charge_port_door" + + # Test setting cover open with unknown error + with patch( + "homeassistant.components.tessie.cover.open_unlock_charge_port", + side_effect=ERROR_UNKNOWN, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert error.from_exception == ERROR_UNKNOWN + + +async def test_response_error(hass: HomeAssistant) -> None: + """Tests response errors are handled.""" + + await setup_platform(hass) + entity_id = "cover.test_charge_port_door" + + # Test setting cover open with unknown error + with patch( + "homeassistant.components.tessie.cover.open_unlock_charge_port", + return_value=TEST_RESPONSE_ERROR, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert str(error) == TEST_RESPONSE_ERROR["reason"] diff --git a/tests/components/tessie/test_device_tracker.py b/tests/components/tessie/test_device_tracker.py new file mode 100644 index 00000000000000..d737b02b40e45f --- /dev/null +++ b/tests/components/tessie/test_device_tracker.py @@ -0,0 +1,36 @@ +"""Test the Tessie device tracker platform.""" + + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant + +from .common import TEST_STATE_OF_ALL_VEHICLES, setup_platform + +STATES = TEST_STATE_OF_ALL_VEHICLES["results"][0]["last_state"] + + +async def test_device_tracker(hass: HomeAssistant) -> None: + """Tests that the device tracker entities are correct.""" + + assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 2 + + entity_id = "device_tracker.test_location" + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_LATITUDE) == STATES["drive_state"]["latitude"] + assert state.attributes.get(ATTR_LONGITUDE) == STATES["drive_state"]["longitude"] + + entity_id = "device_tracker.test_route" + state = hass.states.get(entity_id) + assert ( + state.attributes.get(ATTR_LATITUDE) + == STATES["drive_state"]["active_route_latitude"] + ) + assert ( + state.attributes.get(ATTR_LONGITUDE) + == STATES["drive_state"]["active_route_longitude"] + ) diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py new file mode 100644 index 00000000000000..68d6fcf7777bc0 --- /dev/null +++ b/tests/components/tessie/test_init.py @@ -0,0 +1,37 @@ +"""Test the Tessie init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .common import ERROR_AUTH, ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform + + +async def test_load_unload(hass: HomeAssistant) -> None: + """Test load and unload.""" + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_auth_failure(hass: HomeAssistant) -> None: + """Test init with an authentication error.""" + + entry = await setup_platform(hass, side_effect=ERROR_AUTH) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unknown_failure(hass: HomeAssistant) -> None: + """Test init with an client response error.""" + + entry = await setup_platform(hass, side_effect=ERROR_UNKNOWN) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_connection_failure(hass: HomeAssistant) -> None: + """Test init with a network connection error.""" + + entry = await setup_platform(hass, side_effect=ERROR_CONNECTION) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py new file mode 100644 index 00000000000000..93a1151a850e78 --- /dev/null +++ b/tests/components/tessie/test_lock.py @@ -0,0 +1,50 @@ +"""Test the Tessie lock platform.""" + +from unittest.mock import patch + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_locks(hass: HomeAssistant) -> None: + """Tests that the lock entity is correct.""" + + assert len(hass.states.async_all("lock")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("lock")) == 1 + + entity_id = "lock.test_lock" + + assert ( + hass.states.get(entity_id).state == STATE_LOCKED + ) == TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["locked"] + + # Test lock set value functions + with patch("homeassistant.components.tessie.lock.lock") as mock_run: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_LOCKED + mock_run.assert_called_once() + + with patch("homeassistant.components.tessie.lock.unlock") as mock_run: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_UNLOCKED + mock_run.assert_called_once() diff --git a/tests/components/tessie/test_media_player.py b/tests/components/tessie/test_media_player.py new file mode 100644 index 00000000000000..f658fe28acd0db --- /dev/null +++ b/tests/components/tessie/test_media_player.py @@ -0,0 +1,46 @@ +"""Test the Tessie media player platform.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.core import HomeAssistant + +from .common import ( + TEST_STATE_OF_ALL_VEHICLES, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + +from tests.common import async_fire_time_changed + +WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) + +MEDIA_INFO_1 = TEST_STATE_OF_ALL_VEHICLES["results"][0]["last_state"]["vehicle_state"][ + "media_info" +] +MEDIA_INFO_2 = TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["media_info"] + + +async def test_media_player_idle( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion +) -> None: + """Tests that the media player entity is correct when idle.""" + + assert len(hass.states.async_all("media_player")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("media_player")) == 1 + + state = hass.states.get("media_player.test_media_player") + assert state == snapshot + + # Trigger coordinator refresh since it has a different fixture. + freezer.tick(WAIT) + async_fire_time_changed(hass) + + state = hass.states.get("media_player.test_media_player") + assert state == snapshot diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py new file mode 100644 index 00000000000000..116c9a2657d1d9 --- /dev/null +++ b/tests/components/tessie/test_number.py @@ -0,0 +1,71 @@ +"""Test the Tessie number platform.""" + +from unittest.mock import patch + +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.tessie.number import DESCRIPTIONS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_numbers(hass: HomeAssistant) -> None: + """Tests that the number entities are correct.""" + + assert len(hass.states.async_all("number")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("number")) == len(DESCRIPTIONS) + + assert hass.states.get("number.test_charge_current").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_current_request"] + ) + + assert hass.states.get("number.test_charge_limit").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_limit_soc"] + ) + + assert hass.states.get("number.test_speed_limit").state == str( + TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["speed_limit_mode"][ + "current_limit_mph" + ] + ) + + # Test number set value functions + with patch( + "homeassistant.components.tessie.number.set_charging_amps", + ) as mock_set_charging_amps: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ["number.test_charge_current"], "value": 16}, + blocking=True, + ) + assert hass.states.get("number.test_charge_current").state == "16.0" + mock_set_charging_amps.assert_called_once() + + with patch( + "homeassistant.components.tessie.number.set_charge_limit", + ) as mock_set_charge_limit: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ["number.test_charge_limit"], "value": 80}, + blocking=True, + ) + assert hass.states.get("number.test_charge_limit").state == "80.0" + mock_set_charge_limit.assert_called_once() + + with patch( + "homeassistant.components.tessie.number.set_speed_limit", + ) as mock_set_speed_limit: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ["number.test_speed_limit"], "value": 60}, + blocking=True, + ) + assert hass.states.get("number.test_speed_limit").state == "60.0" + mock_set_speed_limit.assert_called_once() diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py new file mode 100644 index 00000000000000..09afa9306a7333 --- /dev/null +++ b/tests/components/tessie/test_select.py @@ -0,0 +1,65 @@ +"""Test the Tessie select platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.tessie.const import TessieSeatHeaterOptions +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import ERROR_UNKNOWN, TEST_RESPONSE, setup_platform + + +async def test_select(hass: HomeAssistant) -> None: + """Tests that the select entities are correct.""" + + assert len(hass.states.async_all(SELECT_DOMAIN)) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all(SELECT_DOMAIN)) == 5 + + entity_id = "select.test_seat_heater_left" + assert hass.states.get(entity_id).state == STATE_OFF + + # Test changing select + with patch( + "homeassistant.components.tessie.select.set_seat_heat", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: [entity_id], ATTR_OPTION: TessieSeatHeaterOptions.LOW}, + blocking=True, + ) + mock_set.assert_called_once() + assert mock_set.call_args[1]["seat"] == "front_left" + assert mock_set.call_args[1]["level"] == 1 + assert hass.states.get(entity_id).state == TessieSeatHeaterOptions.LOW + + +async def test_errors(hass: HomeAssistant) -> None: + """Tests unknown error is handled.""" + + await setup_platform(hass) + entity_id = "select.test_seat_heater_left" + + # Test setting cover open with unknown error + with patch( + "homeassistant.components.tessie.select.set_seat_heat", + side_effect=ERROR_UNKNOWN, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: [entity_id], ATTR_OPTION: TessieSeatHeaterOptions.LOW}, + blocking=True, + ) + mock_set.assert_called_once() + assert error.from_exception == ERROR_UNKNOWN diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py new file mode 100644 index 00000000000000..0c719f661361af --- /dev/null +++ b/tests/components/tessie/test_sensor.py @@ -0,0 +1,24 @@ +"""Test the Tessie sensor platform.""" +from homeassistant.components.tessie.sensor import DESCRIPTIONS +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_sensors(hass: HomeAssistant) -> None: + """Tests that the sensor entities are correct.""" + + assert len(hass.states.async_all("sensor")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("sensor")) == len(DESCRIPTIONS) + + assert hass.states.get("sensor.test_battery_level").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_level"] + ) + assert hass.states.get("sensor.test_charge_energy_added").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_energy_added"] + ) + assert hass.states.get("sensor.test_shift_state").state == STATE_UNKNOWN diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py new file mode 100644 index 00000000000000..5bc24d12e5c14f --- /dev/null +++ b/tests/components/tessie/test_switch.py @@ -0,0 +1,53 @@ +"""Test the Tessie switch platform.""" +from unittest.mock import patch + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.tessie.switch import DESCRIPTIONS +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_switches(hass: HomeAssistant) -> None: + """Tests that the switche entities are correct.""" + + assert len(hass.states.async_all("switch")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("switch")) == len(DESCRIPTIONS) + + assert (hass.states.get("switch.test_charge").state == STATE_ON) == ( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_enable_request"] + ) + assert (hass.states.get("switch.test_sentry_mode").state == STATE_ON) == ( + TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["sentry_mode"] + ) + + with patch( + "homeassistant.components.tessie.switch.start_charging", + ) as mock_start_charging: + # Test Switch On + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ["switch.test_charge"]}, + blocking=True, + ) + mock_start_charging.assert_called_once() + with patch( + "homeassistant.components.tessie.switch.stop_charging", + ) as mock_stop_charging: + # Test Switch Off + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ["switch.test_charge"]}, + blocking=True, + ) + mock_stop_charging.assert_called_once() diff --git a/tests/components/tessie/test_update.py b/tests/components/tessie/test_update.py new file mode 100644 index 00000000000000..182acdf17ffc32 --- /dev/null +++ b/tests/components/tessie/test_update.py @@ -0,0 +1,42 @@ +"""Test the Tessie update platform.""" +from unittest.mock import patch + +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant + +from .common import setup_platform + + +async def test_updates(hass: HomeAssistant) -> None: + """Tests that update entity is correct.""" + + assert len(hass.states.async_all("update")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("update")) == 1 + + entity_id = "update.test_update" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes.get(ATTR_IN_PROGRESS) is False + + with patch( + "homeassistant.components.tessie.update.schedule_software_update" + ) as mock_update: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_update.assert_called_once() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes.get(ATTR_IN_PROGRESS) == 1 diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index 96c7edf422b99d..e8741a4342776c 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -1,174 +1,329 @@ """The tests for time_date sensor platform.""" -from unittest.mock import patch +from unittest.mock import ANY, Mock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.time_date.const import DOMAIN import homeassistant.components.time_date.sensor as time_date from homeassistant.core import HomeAssistant +from homeassistant.helpers import event, issue_registry as ir +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from tests.common import async_fire_time_changed + +ALL_DISPLAY_OPTIONS = list(time_date.OPTION_TYPES.keys()) +CONFIG = {"sensor": {"platform": "time_date", "display_options": ALL_DISPLAY_OPTIONS}} + + +@patch("homeassistant.components.time_date.sensor.async_track_point_in_utc_time") +@pytest.mark.parametrize( + ("display_option", "start_time", "tracked_time"), + [ + ( + "time", + dt_util.utc_from_timestamp(45.5), + dt_util.utc_from_timestamp(60), + ), + ( + "beat", + dt_util.parse_datetime("2020-11-13 00:00:29+01:00"), + dt_util.parse_datetime("2020-11-13 00:01:26.4+01:00"), + ), + ( + "date_time", + dt_util.utc_from_timestamp(1495068899), + dt_util.utc_from_timestamp(1495068900), + ), + ( + "time_date", + dt_util.utc_from_timestamp(1495068899), + dt_util.utc_from_timestamp(1495068900), + ), + ], +) +async def test_intervals( + mock_track_interval: Mock, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + display_option: str, + start_time, + tracked_time, +) -> None: + """Test timing intervals of sensors when time zone is UTC.""" + hass.config.set_time_zone("UTC") + config = {"sensor": {"platform": "time_date", "display_options": [display_option]}} -async def test_intervals(hass: HomeAssistant) -> None: - """Test timing intervals of sensors.""" - device = time_date.TimeDateSensor(hass, "time") - now = dt_util.utc_from_timestamp(45.5) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() - assert next_time == dt_util.utc_from_timestamp(60) - - device = time_date.TimeDateSensor(hass, "beat") - now = dt_util.parse_datetime("2020-11-13 00:00:29+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() - assert next_time == dt_util.parse_datetime("2020-11-13 00:01:26.4+01:00") + freezer.move_to(start_time) - device = time_date.TimeDateSensor(hass, "date_time") - now = dt_util.utc_from_timestamp(1495068899) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() - assert next_time == dt_util.utc_from_timestamp(1495068900) + await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() - now = dt_util.utcnow() - device = time_date.TimeDateSensor(hass, "time_date") - next_time = device.get_next_interval() - assert next_time > now + mock_track_interval.assert_called_once_with(hass, ANY, tracked_time) -async def test_states(hass: HomeAssistant) -> None: +async def test_states(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test states of sensors.""" hass.config.set_time_zone("UTC") - now = dt_util.utc_from_timestamp(1495068856) - device = time_date.TimeDateSensor(hass, "time") - device._update_internal_state(now) - assert device.state == "00:54" + freezer.move_to(now) + + await async_setup_component(hass, "sensor", CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("sensor.time") + assert state.state == "00:54" + + state = hass.states.get("sensor.date") + assert state.state == "2017-05-18" + + state = hass.states.get("sensor.time_utc") + assert state.state == "00:54" - device = time_date.TimeDateSensor(hass, "date") - device._update_internal_state(now) - assert device.state == "2017-05-18" + state = hass.states.get("sensor.date_time") + assert state.state == "2017-05-18, 00:54" - device = time_date.TimeDateSensor(hass, "time_utc") - device._update_internal_state(now) - assert device.state == "00:54" + state = hass.states.get("sensor.date_time_utc") + assert state.state == "2017-05-18, 00:54" - device = time_date.TimeDateSensor(hass, "date_time") - device._update_internal_state(now) - assert device.state == "2017-05-18, 00:54" + state = hass.states.get("sensor.internet_time") + assert state.state == "@079" - device = time_date.TimeDateSensor(hass, "date_time_utc") - device._update_internal_state(now) - assert device.state == "2017-05-18, 00:54" + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2017-05-18T00:54:00" - device = time_date.TimeDateSensor(hass, "beat") - device._update_internal_state(now) - assert device.state == "@079" - device._update_internal_state(dt_util.utc_from_timestamp(1602952963.2)) - assert device.state == "@738" + # Time travel + now = dt_util.utc_from_timestamp(1602952963.2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - device = time_date.TimeDateSensor(hass, "date_time_iso") - device._update_internal_state(now) - assert device.state == "2017-05-18T00:54:00" + state = hass.states.get("sensor.time") + assert state.state == "16:42" + state = hass.states.get("sensor.date") + assert state.state == "2020-10-17" -async def test_states_non_default_timezone(hass: HomeAssistant) -> None: + state = hass.states.get("sensor.time_utc") + assert state.state == "16:42" + + state = hass.states.get("sensor.date_time") + assert state.state == "2020-10-17, 16:42" + + state = hass.states.get("sensor.date_time_utc") + assert state.state == "2020-10-17, 16:42" + + state = hass.states.get("sensor.internet_time") + assert state.state == "@738" + + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2020-10-17T16:42:00" + + +async def test_states_non_default_timezone( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test states of sensors in a timezone other than UTC.""" hass.config.set_time_zone("America/New_York") - now = dt_util.utc_from_timestamp(1495068856) - device = time_date.TimeDateSensor(hass, "time") - device._update_internal_state(now) - assert device.state == "20:54" + freezer.move_to(now) - device = time_date.TimeDateSensor(hass, "date") - device._update_internal_state(now) - assert device.state == "2017-05-17" + await async_setup_component(hass, "sensor", CONFIG) + await hass.async_block_till_done() - device = time_date.TimeDateSensor(hass, "time_utc") - device._update_internal_state(now) - assert device.state == "00:54" + state = hass.states.get("sensor.time") + assert state.state == "20:54" - device = time_date.TimeDateSensor(hass, "date_time") - device._update_internal_state(now) - assert device.state == "2017-05-17, 20:54" + state = hass.states.get("sensor.date") + assert state.state == "2017-05-17" - device = time_date.TimeDateSensor(hass, "date_time_utc") - device._update_internal_state(now) - assert device.state == "2017-05-18, 00:54" + state = hass.states.get("sensor.time_utc") + assert state.state == "00:54" - device = time_date.TimeDateSensor(hass, "beat") - device._update_internal_state(now) - assert device.state == "@079" + state = hass.states.get("sensor.date_time") + assert state.state == "2017-05-17, 20:54" - device = time_date.TimeDateSensor(hass, "date_time_iso") - device._update_internal_state(now) - assert device.state == "2017-05-17T20:54:00" + state = hass.states.get("sensor.date_time_utc") + assert state.state == "2017-05-18, 00:54" + state = hass.states.get("sensor.internet_time") + assert state.state == "@079" -async def test_timezone_intervals(hass: HomeAssistant) -> None: - """Test date sensor behavior in a timezone besides UTC.""" - hass.config.set_time_zone("America/New_York") + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2017-05-17T20:54:00" + + # Time travel + now = dt_util.utc_from_timestamp(1602952963.2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.time") + assert state.state == "12:42" - device = time_date.TimeDateSensor(hass, "date") - now = dt_util.utc_from_timestamp(50000) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() - # start of local day in EST was 18000.0 - # so the second day was 18000 + 86400 - assert next_time.timestamp() == 104400 - - hass.config.set_time_zone("America/Edmonton") - now = dt_util.parse_datetime("2017-11-13 19:47:19-07:00") - device = time_date.TimeDateSensor(hass, "date") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") - - # Entering DST - hass.config.set_time_zone("Europe/Prague") - - now = dt_util.parse_datetime("2020-03-29 00:00+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") - - now = dt_util.parse_datetime("2020-03-29 03:00+02:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") - - # Leaving DST - now = dt_util.parse_datetime("2020-10-25 00:00+02:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") - - now = dt_util.parse_datetime("2020-10-25 23:59+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") + state = hass.states.get("sensor.date") + assert state.state == "2020-10-17" + + state = hass.states.get("sensor.time_utc") + assert state.state == "16:42" + + state = hass.states.get("sensor.date_time") + assert state.state == "2020-10-17, 12:42" + + state = hass.states.get("sensor.date_time_utc") + assert state.state == "2020-10-17, 16:42" + + state = hass.states.get("sensor.internet_time") + assert state.state == "@738" + + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2020-10-17T12:42:00" + + # Change time zone + await hass.config.async_update(time_zone="Europe/Prague") + await hass.async_block_till_done() + + state = hass.states.get("sensor.time") + assert state.state == "18:42" + + state = hass.states.get("sensor.date") + assert state.state == "2020-10-17" + + state = hass.states.get("sensor.time_utc") + assert state.state == "16:42" + + state = hass.states.get("sensor.date_time") + assert state.state == "2020-10-17, 18:42" + + state = hass.states.get("sensor.date_time_utc") + assert state.state == "2020-10-17, 16:42" + + state = hass.states.get("sensor.internet_time") + assert state.state == "@738" + + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2020-10-17T18:42:00" @patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.parse_datetime("2017-11-14 02:47:19-00:00"), + "homeassistant.components.time_date.sensor.async_track_point_in_utc_time", + side_effect=event.async_track_point_in_utc_time, +) +@pytest.mark.parametrize( + ("time_zone", "start_time", "tracked_time"), + [ + ( + "America/New_York", + dt_util.utc_from_timestamp(50000), + # start of local day in EST was 18000.0 + # so the second day was 18000 + 86400 + 104400, + ), + ( + "America/Edmonton", + dt_util.parse_datetime("2017-11-13 19:47:19-07:00"), + dt_util.as_timestamp("2017-11-14 00:00:00-07:00"), + ), + # Entering DST + ( + "Europe/Prague", + dt_util.parse_datetime("2020-03-29 00:00+01:00"), + dt_util.as_timestamp("2020-03-30 00:00+02:00"), + ), + ( + "Europe/Prague", + dt_util.parse_datetime("2020-03-29 03:00+02:00"), + dt_util.as_timestamp("2020-03-30 00:00+02:00"), + ), + # Leaving DST + ( + "Europe/Prague", + dt_util.parse_datetime("2020-10-25 00:00+02:00"), + dt_util.as_timestamp("2020-10-26 00:00+01:00"), + ), + ( + "Europe/Prague", + dt_util.parse_datetime("2020-10-25 23:59+01:00"), + dt_util.as_timestamp("2020-10-26 00:00+01:00"), + ), + ], ) -async def test_timezone_intervals_empty_parameter( - utcnow_mock, hass: HomeAssistant +async def test_timezone_intervals( + mock_track_interval: Mock, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + time_zone: str, + start_time, + tracked_time, ) -> None: - """Test get_interval() without parameters.""" - hass.config.set_time_zone("America/Edmonton") - device = time_date.TimeDateSensor(hass, "date") - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") + """Test timing intervals of sensors in timezone other than UTC.""" + hass.config.set_time_zone(time_zone) + freezer.move_to(start_time) + + config = {"sensor": {"platform": "time_date", "display_options": ["date"]}} + await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + mock_track_interval.assert_called_once() + next_time = mock_track_interval.mock_calls[0][1][2] + + assert next_time.timestamp() == tracked_time async def test_icons(hass: HomeAssistant) -> None: """Test attributes of sensors.""" - device = time_date.TimeDateSensor(hass, "time") - assert device.icon == "mdi:clock" - device = time_date.TimeDateSensor(hass, "date") - assert device.icon == "mdi:calendar" - device = time_date.TimeDateSensor(hass, "date_time") - assert device.icon == "mdi:calendar-clock" - device = time_date.TimeDateSensor(hass, "date_time_utc") - assert device.icon == "mdi:calendar-clock" - device = time_date.TimeDateSensor(hass, "date_time_iso") - assert device.icon == "mdi:calendar-clock" + await async_setup_component(hass, "sensor", CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("sensor.time") + assert state.attributes["icon"] == "mdi:clock" + state = hass.states.get("sensor.date") + assert state.attributes["icon"] == "mdi:calendar" + state = hass.states.get("sensor.time_utc") + assert state.attributes["icon"] == "mdi:clock" + state = hass.states.get("sensor.date_time") + assert state.attributes["icon"] == "mdi:calendar-clock" + state = hass.states.get("sensor.date_time_utc") + assert state.attributes["icon"] == "mdi:calendar-clock" + state = hass.states.get("sensor.internet_time") + assert state.attributes["icon"] == "mdi:clock" + state = hass.states.get("sensor.date_time_iso") + assert state.attributes["icon"] == "mdi:calendar-clock" + + +@pytest.mark.parametrize( + ( + "display_options", + "expected_warnings", + "expected_issues", + ), + [ + (["time", "date"], [], []), + (["beat"], ["'beat': is deprecated"], ["deprecated_beat"]), + (["time", "beat"], ["'beat': is deprecated"], ["deprecated_beat"]), + ], +) +async def test_deprecation_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + display_options: list[str], + expected_warnings: list[str], + expected_issues: list[str], +) -> None: + """Test deprecation warning for swatch beat.""" + config = {"sensor": {"platform": "time_date", "display_options": display_options}} + + await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + warnings = [record for record in caplog.records if record.levelname == "WARNING"] + assert len(warnings) == len(expected_warnings) + for expected_warning in expected_warnings: + assert any(expected_warning in warning.message for warning in warnings) + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == len(expected_issues) + for expected_issue in expected_issues: + assert (DOMAIN, expected_issue) in issue_registry.issues diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 3e84049efa8ad9..5a8f6183cbbbb7 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 @@ 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.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback from tests.common import ( @@ -31,12 +35,45 @@ 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 @@ async def async_unload_entry_init( ) +@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], @@ -103,10 +146,19 @@ async def async_setup_entry_platform( return config_entry +@pytest.fixture(name="test_entity_items") +def mock_test_entity_items() -> list[TodoItem]: + """Fixture that creates the items returned by the test entity.""" + return [ + TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), + ] + + @pytest.fixture(name="test_entity") -def mock_test_entity() -> TodoListEntity: +def mock_test_entity(test_entity_items: list[TodoItem]) -> TodoListEntity: """Fixture that creates a test TodoList entity with mock service calls.""" - entity1 = TodoListEntity() + entity1 = MockTodoListEntity(test_entity_items) entity1.entity_id = "todo.entity1" entity1._attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM @@ -114,13 +166,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 +216,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 +341,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"}, + ServiceValidationError, + "does not support setting field 'description'", + ), + ( + {"item": "Submit forms", "due_date": "2023-11-17"}, + ServiceValidationError, + "does not support setting field 'due_date'", + ), + ( + { + "item": "Submit forms", + "due_datetime": f"2023-11-17T17:00:00{TEST_OFFSET}", + }, + ServiceValidationError, + "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 +386,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, @@ -314,7 +508,7 @@ async def test_update_todo_item_service_by_id_status_only( item = args.kwargs.get("item") assert item assert item.uid == "1" - assert item.summary is None + assert item.summary == "Item #1" assert item.status == TodoItemStatus.COMPLETED @@ -340,7 +534,7 @@ async def test_update_todo_item_service_by_id_rename( assert item assert item.uid == "1" assert item.summary == "Updated item" - assert item.status is None + assert item.status == TodoItemStatus.NEEDS_ACTION async def test_update_todo_item_service_raises( @@ -417,7 +611,7 @@ async def test_update_todo_item_service_by_summary_only_status( assert item assert item.uid == "1" assert item.summary == "Something else" - assert item.status is None + assert item.status == TodoItemStatus.NEEDS_ACTION async def test_update_todo_item_service_by_summary_not_found( @@ -428,7 +622,7 @@ async def test_update_todo_item_service_by_summary_not_found( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( DOMAIN, "update_item", @@ -470,6 +664,184 @@ 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(ServiceValidationError, 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", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, + 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", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, + {"description": "Submit revised draft"}, + TodoItem( + uid="1", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, + 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 + + +@pytest.mark.parametrize( + ("test_entity_items", "update_data", "expected_update"), + ( + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": "Submit revised draft"}, + TodoItem(uid="1", summary="Summary", description="Submit revised draft"), + ), + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": ""}, + TodoItem(uid="1", summary="Summary", description=""), + ), + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": None}, + TodoItem(uid="1", summary="Summary"), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_date": datetime.date(2024, 1, 2)}, + TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 2)), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_date": None}, + TodoItem(uid="1", summary="Summary"), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_datetime": datetime.datetime(2024, 1, 1, 10, 0, 0)}, + TodoItem( + uid="1", + summary="Summary", + due=datetime.datetime( + 2024, 1, 1, 10, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="America/Regina") + ), + ), + ), + ( + [ + TodoItem( + uid="1", + summary="Summary", + due=datetime.datetime(2024, 1, 1, 10, 0, 0), + ) + ], + {"due_datetime": None}, + TodoItem(uid="1", summary="Summary"), + ), + ), + ids=[ + "overwrite_description", + "overwrite_empty_description", + "clear_description", + "overwrite_due_date", + "clear_due_date", + "overwrite_due_date_with_time", + "clear_due_date_time", + ], +) +async def test_update_todo_item_extended_fields_overwrite_existing_values( + hass: HomeAssistant, + test_entity: TodoListEntity, + update_data: dict[str, Any], + expected_update: TodoItem, +) -> None: + """Test updating an item in a To-do list.""" + + test_entity._attr_supported_features |= ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + ) + 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, @@ -559,7 +931,7 @@ async def test_remove_todo_item_service_by_summary_not_found( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( DOMAIN, "remove_item", @@ -688,12 +1060,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 +1113,297 @@ 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" + assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION + entity1.items.clear() + + # Add to second list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"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" + assert entity2.items[0].status == TodoItemStatus.NEEDS_ACTION + + # List name is case insensitive + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"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" + assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION + + # 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 28f22e1061a181..42251b0ea18b78 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 a14f362ea5be73..5aa1e2af9de807 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 .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,92 @@ 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", "due_string": "no date", "description": ""}, + {"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"), + ) + ], + {"description": "", "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", + ), + ) + ], + { + "description": "", + "due_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", "due_string": "no date"}, + { + "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 +168,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 +262,157 @@ 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, + description="desc", + ) + ], + {"rename": "Milk"}, + [ + make_api_task( + id="task-id-1", + content="Milk", + is_completed=False, + description="desc", + ) + ], + { + "task_id": "task-id-1", + "content": "Milk", + "description": "desc", + "due_string": "no date", + }, + { + "uid": "task-id-1", + "summary": "Milk", + "status": "needs_action", + "description": "desc", + }, + ), + ( + [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", + "content": "Soda", + "due_date": "2023-11-18", + "description": "", + }, + { + "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", + "content": "Soda", + "due_datetime": "2023-11-18T06:30:00-06:00", + "description": "", + }, + { + "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", + "content": "Soda", + "description": "6-pack", + "due_string": "no date", + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ( + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"description": None}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + description="", + ) + ], + { + "task_id": "task-id-1", + "content": "Soda", + "description": "", + "due_string": "no date", + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + }, + ), + ], + ids=["rename", "due_date", "due_datetime", "description", "clear_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 +423,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 +488,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/tomato/test_device_tracker.py b/tests/components/tomato/test_device_tracker.py index 7c187c7b4bbf0f..11e73b5695cc2e 100644 --- a/tests/components/tomato/test_device_tracker.py +++ b/tests/components/tomato/test_device_tracker.py @@ -157,7 +157,7 @@ def test_config_verify_ssl_but_no_ssl_enabled( assert "_http_id=1234567890" in result.req.body assert "exec=devlist" in result.req.body assert mock_session_send.call_count == 1 - assert mock_session_send.mock_calls[0] == mock.call(result.req, timeout=3) + assert mock_session_send.mock_calls[0] == mock.call(result.req, timeout=60) @mock.patch("os.access", return_value=True) @@ -192,7 +192,7 @@ def test_config_valid_verify_ssl_path(hass: HomeAssistant, mock_session_send) -> assert "exec=devlist" in result.req.body assert mock_session_send.call_count == 1 assert mock_session_send.mock_calls[0] == mock.call( - result.req, timeout=3, verify="/test/tomato.crt" + result.req, timeout=60, verify="/test/tomato.crt" ) @@ -223,7 +223,7 @@ def test_config_valid_verify_ssl_bool(hass: HomeAssistant, mock_session_send) -> assert "exec=devlist" in result.req.body assert mock_session_send.call_count == 1 assert mock_session_send.mock_calls[0] == mock.call( - result.req, timeout=3, verify=False + result.req, timeout=60, verify=False ) diff --git a/tests/components/tomorrowio/snapshots/test_weather.ambr b/tests/components/tomorrowio/snapshots/test_weather.ambr index a938cb10e44ef8..fe65925e4c7d7f 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 863623ee524c73..e715fccea6b017 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -46,7 +46,8 @@ 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,11 +306,10 @@ async def test_v4_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot -async def test_v4_bad_forecast( +async def test_legacy_v4_bad_forecast( hass: HomeAssistant, freezer: FrozenDateTimeFactory, tomorrowio_config_entry_update, @@ -321,7 +329,7 @@ async def test_v4_bad_forecast( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, { "entity_id": entity_id, "type": "hourly", @@ -332,6 +340,42 @@ async def test_v4_bad_forecast( assert response["forecast"][0]["precipitation_probability"] is None +async def test_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, + SERVICE_GET_FORECASTS, + { + "entity_id": entity_id, + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert ( + response["weather.tomorrow_io_daily"]["forecast"][0][ + "precipitation_probability" + ] + is None + ) + + @pytest.mark.parametrize("forecast_type", ["daily", "hourly"]) async def test_forecast_subscription( hass: HomeAssistant, diff --git a/tests/components/trafikverket_camera/__init__.py b/tests/components/trafikverket_camera/__init__.py index 026c122fb57a9e..dd23c7bce7ed2f 100644 --- a/tests/components/trafikverket_camera/__init__.py +++ b/tests/components/trafikverket_camera/__init__.py @@ -1,10 +1,14 @@ """Tests for the Trafikverket Camera integration.""" 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, CONF_LOCATION 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 a4902ac2950b25..a5eeb707b34a2e 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 6f7eb54028917e..87d0e6d58b76f8 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 b3df7cfcdcb8e7..182924e9f0ed21 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 b53763c0ac7906..ca1d8554c4a3f4 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -13,8 +13,8 @@ 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.components.trafikverket_camera.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION 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 4183aa9fffa474..0f79307e0b6a1b 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 83a3fc1486a7b5..e10c6c16e331e1 100644 --- a/tests/components/trafikverket_camera/test_init.py +++ b/tests/components/trafikverket_camera/test_init.py @@ -4,6 +4,7 @@ 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.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 b9add7ae4830bd..777c6ea26b329d 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 581fed1d28960e..c1c98aed79735f 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/__init__.py b/tests/components/trafikverket_train/__init__.py index 060b6a344a17c3..9a02ebbf3b6369 100644 --- a/tests/components/trafikverket_train/__init__.py +++ b/tests/components/trafikverket_train/__init__.py @@ -1 +1,28 @@ """Tests for the Trafikverket Train integration.""" +from __future__ import annotations + +from homeassistant.components.trafikverket_ferry.const import ( + CONF_FROM, + CONF_TIME, + CONF_TO, +) +from homeassistant.components.trafikverket_train.const import CONF_FILTER_PRODUCT +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS + +ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: None, + CONF_WEEKDAY: WEEKDAYS, + CONF_NAME: "Stockholm C to Uppsala C", +} +ENTRY_CONFIG2 = { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "11:00:00", + CONF_WEEKDAY: WEEKDAYS, + CONF_NAME: "Stockholm C to Uppsala C", +} +OPTIONS_CONFIG = {CONF_FILTER_PRODUCT: "Regionaltåg"} diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py new file mode 100644 index 00000000000000..423dee541d266c --- /dev/null +++ b/tests/components/trafikverket_train/conftest.py @@ -0,0 +1,160 @@ +"""Fixtures for Trafikverket Train integration tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import patch + +import pytest +from pytrafikverket.trafikverket_train import TrainStop + +from homeassistant.components.trafikverket_train.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import ENTRY_CONFIG, ENTRY_CONFIG2, OPTIONS_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="load_int") +async def load_integration_from_entry( + hass: HomeAssistant, + get_trains: list[TrainStop], + get_train_stop: TrainStop, +) -> MockConfigEntry: + """Set up the Trafikverket Train integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="stockholmc-uppsalac--['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + ) + config_entry.add_to_hass(hass) + config_entry2 = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG2, + entry_id="2", + unique_id="stockholmc-uppsalac-1100-['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + ) + config_entry2.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=get_train_stop, + ), patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="get_trains") +def fixture_get_trains() -> list[TrainStop]: + """Construct TrainStop Mock.""" + + depart1 = TrainStop( + id=13, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + estimated_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart2 = TrainStop( + id=14, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=15), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart3 = TrainStop( + id=15, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=30), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + + return [depart1, depart2, depart3] + + +@pytest.fixture(name="get_trains_next") +def fixture_get_trains_next() -> list[TrainStop]: + """Construct TrainStop Mock.""" + + depart1 = TrainStop( + id=13, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC), + estimated_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC), + time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC), + other_information=None, + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart2 = TrainStop( + id=14, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=15), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart3 = TrainStop( + id=15, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=30), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + + return [depart1, depart2, depart3] + + +@pytest.fixture(name="get_train_stop") +def fixture_get_train_stop() -> TrainStop: + """Construct TrainStop Mock.""" + + return TrainStop( + id=13, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + estimated_time_at_location=None, + time_at_location=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + other_information=None, + deviations=None, + modified_time=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) diff --git a/tests/components/trafikverket_train/snapshots/test_init.ambr b/tests/components/trafikverket_train/snapshots/test_init.ambr new file mode 100644 index 00000000000000..c32995fdb76899 --- /dev/null +++ b/tests/components/trafikverket_train/snapshots/test_init.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_auth_failed + FlowResultSnapshot({ + 'context': dict({ + 'entry_id': '1', + 'source': 'reauth', + 'title_placeholders': dict({ + 'name': 'Mock Title', + }), + 'unique_id': '321', + }), + 'flow_id': , + 'handler': 'trafikverket_train', + 'step_id': 'reauth_confirm', + }) +# --- diff --git a/tests/components/trafikverket_train/snapshots/test_sensor.ambr b/tests/components/trafikverket_train/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..6ea0168926e5ab --- /dev/null +++ b/tests/components/trafikverket_train/snapshots/test_sensor.ambr @@ -0,0 +1,231 @@ +# serializer version: 1 +# name: test_sensor_next + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:00:00+00:00', + }) +# --- +# name: test_sensor_next.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'enum', + 'friendly_name': 'Stockholm C to Uppsala C Departure state', + 'icon': 'mdi:clock', + 'options': list([ + 'on_time', + 'delayed', + 'canceled', + ]), + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_state', + 'last_changed': , + 'last_updated': , + 'state': 'on_time', + }) +# --- +# name: test_sensor_next.10 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:15:00+00:00', + }) +# --- +# name: test_sensor_next.11 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next after', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next_after', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:30:00+00:00', + }) +# --- +# name: test_sensor_next.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Actual time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_actual_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:00:00+00:00', + }) +# --- +# name: test_sensor_next.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'friendly_name': 'Stockholm C to Uppsala C Other information', + 'icon': 'mdi:information-variant', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_other_information', + 'last_changed': , + 'last_updated': , + 'state': 'Some other info', + }) +# --- +# name: test_sensor_next.4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:15:00+00:00', + }) +# --- +# name: test_sensor_next.5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next after', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next_after', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:30:00+00:00', + }) +# --- +# name: test_sensor_next.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:00:00+00:00', + }) +# --- +# name: test_sensor_next.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'enum', + 'friendly_name': 'Stockholm C to Uppsala C Departure state', + 'icon': 'mdi:clock', + 'options': list([ + 'on_time', + 'delayed', + 'canceled', + ]), + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_state', + 'last_changed': , + 'last_updated': , + 'state': 'on_time', + }) +# --- +# name: test_sensor_next.8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Actual time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_actual_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:00:00+00:00', + }) +# --- +# name: test_sensor_next.9 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'friendly_name': 'Stockholm C to Uppsala C Other information', + 'icon': 'mdi:information-variant', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_other_information', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_single_stop + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time', + 'icon': 'mdi:clock', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_2', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T11:00:00+00:00', + }) +# --- +# name: test_sensor_update_auth_failure + FlowResultSnapshot({ + 'context': dict({ + 'entry_id': '1', + 'source': 'reauth', + 'title_placeholders': dict({ + 'name': 'Mock Title', + }), + 'unique_id': "stockholmc-uppsalac--['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + }), + 'flow_id': , + 'handler': 'trafikverket_train', + 'step_id': 'reauth_confirm', + }) +# --- diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 3493e031669490..f56aee163bc723 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -6,12 +6,12 @@ import pytest from pytrafikverket.exceptions import ( InvalidAuthentication, - MultipleTrainAnnouncementFound, MultipleTrainStationsFound, NoTrainAnnouncementFound, NoTrainStationFound, UnknownError, ) +from pytrafikverket.trafikverket_train import TrainStop from homeassistant import config_entries from homeassistant.components.trafikverket_train.const import ( @@ -177,10 +177,6 @@ async def test_flow_fails( NoTrainAnnouncementFound, "no_trains", ), - ( - MultipleTrainAnnouncementFound, - "multiple_trains", - ), ( UnknownError, "cannot_connect", @@ -201,7 +197,7 @@ async def test_flow_fails_departures( with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", ), patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stops", side_effect=side_effect(), ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -371,10 +367,6 @@ async def test_reauth_flow_error( NoTrainAnnouncementFound, "no_trains", ), - ( - MultipleTrainAnnouncementFound, - "multiple_trains", - ), ( UnknownError, "cannot_connect", @@ -451,7 +443,11 @@ async def test_reauth_flow_error_departures( } -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + get_trains: list[TrainStop], + get_train_stop: TrainStop, +) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( domain=DOMAIN, @@ -468,36 +464,41 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.async_setup_entry", - return_value=True, + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=get_train_stop, ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"filter_product": "SJ Regionaltåg"}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"filter_product": "SJ Regionaltåg"}, + ) + await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"filter_product": "SJ Regionaltåg"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {"filter_product": "SJ Regionaltåg"} - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"filter_product": ""}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"filter_product": ""}, + ) + await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"filter_product": None} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {"filter_product": None} diff --git a/tests/components/trafikverket_train/test_init.py b/tests/components/trafikverket_train/test_init.py new file mode 100644 index 00000000000000..74b6f30ce6147c --- /dev/null +++ b/tests/components/trafikverket_train/test_init.py @@ -0,0 +1,143 @@ +"""Test for Trafikverket Train component Init.""" +from __future__ import annotations + +from unittest.mock import patch + +from pytrafikverket.exceptions import InvalidAuthentication, NoTrainStationFound +from pytrafikverket.trafikverket_train import TrainStop +from syrupy.assertion import SnapshotAssertion + +from homeassistant import config_entries +from homeassistant.components.trafikverket_train.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import ENTRY_CONFIG, OPTIONS_CONFIG + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant, get_trains: list[TrainStop]) -> None: + """Test unload an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ) as mock_tv_train: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert len(mock_tv_train.mock_calls) == 1 + + 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 + + +async def test_auth_failed( + hass: HomeAssistant, + get_trains: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test authentication failed.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + side_effect=InvalidAuthentication, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + active_flows = entry.async_get_active_flows(hass, (SOURCE_REAUTH)) + for flow in active_flows: + assert flow == snapshot + + +async def test_no_stations( + hass: HomeAssistant, + get_trains: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test stations are missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + side_effect=NoTrainStationFound, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_migrate_entity_unique_id( + hass: HomeAssistant, + get_trains: list[TrainStop], + snapshot: SnapshotAssertion, + entity_registry: EntityRegistry, +) -> None: + """Test migration of entity unique id in old format.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + entity = entity_registry.async_get_or_create( + DOMAIN, + "sensor", + "incorrect_unique_id", + config_entry=entry, + original_name="Stockholm C to Uppsala C", + ) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + + entity = entity_registry.async_get(entity.entity_id) + assert entity.unique_id == f"{entry.entry_id}-departure_time" diff --git a/tests/components/trafikverket_train/test_sensor.py b/tests/components/trafikverket_train/test_sensor.py new file mode 100644 index 00000000000000..819433a6b9cfcd --- /dev/null +++ b/tests/components/trafikverket_train/test_sensor.py @@ -0,0 +1,157 @@ +"""The test for the Trafikverket train sensor platform.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from pytrafikverket.exceptions import InvalidAuthentication, NoTrainAnnouncementFound +from pytrafikverket.trafikverket_train import TrainStop +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_time_changed + + +async def test_sensor_next( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + get_train_stop: TrainStop, + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor.""" + for entity in ( + "sensor.stockholm_c_to_uppsala_c_departure_time", + "sensor.stockholm_c_to_uppsala_c_departure_state", + "sensor.stockholm_c_to_uppsala_c_actual_time", + "sensor.stockholm_c_to_uppsala_c_other_information", + "sensor.stockholm_c_to_uppsala_c_departure_time_next", + "sensor.stockholm_c_to_uppsala_c_departure_time_next_after", + ): + state = hass.states.get(entity) + assert state == snapshot + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains_next, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=get_train_stop, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity in ( + "sensor.stockholm_c_to_uppsala_c_departure_time", + "sensor.stockholm_c_to_uppsala_c_departure_state", + "sensor.stockholm_c_to_uppsala_c_actual_time", + "sensor.stockholm_c_to_uppsala_c_other_information", + "sensor.stockholm_c_to_uppsala_c_departure_time_next", + "sensor.stockholm_c_to_uppsala_c_departure_time_next_after", + ): + state = hass.states.get(entity) + assert state == snapshot + + +async def test_sensor_single_stop( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + + assert state.state == "2023-05-01T11:00:00+00:00" + + assert state == snapshot + + +async def test_sensor_update_auth_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor with authentication update failure.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == "2023-05-01T11:00:00+00:00" + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + side_effect=InvalidAuthentication, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + side_effect=InvalidAuthentication, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == STATE_UNAVAILABLE + active_flows = load_int.async_get_active_flows(hass, (SOURCE_REAUTH)) + for flow in active_flows: + assert flow == snapshot + + +async def test_sensor_update_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor with update failure.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == "2023-05-01T11:00:00+00:00" + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + side_effect=NoTrainAnnouncementFound, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + side_effect=NoTrainAnnouncementFound, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_update_failure_no_state( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor with update failure from empty state.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == "2023-05-01T11:00:00+00:00" + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=None, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/trafikverket_train/test_util.py b/tests/components/trafikverket_train/test_util.py new file mode 100644 index 00000000000000..e978917adca829 --- /dev/null +++ b/tests/components/trafikverket_train/test_util.py @@ -0,0 +1,25 @@ +"""The test for the Trafikverket train utils.""" +from __future__ import annotations + +from datetime import datetime + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.trafikverket_train.util import next_departuredate +from homeassistant.const import WEEKDAYS +from homeassistant.util import dt as dt_util + + +async def test_sensor_next( + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Trafikverket Train utils.""" + + assert next_departuredate(WEEKDAYS) == dt_util.now().date() + freezer.move_to(datetime(2023, 12, 22)) # Friday + assert ( + next_departuredate(["mon", "tue", "wed", "thu"]) + == datetime(2023, 12, 25).date() + ) + freezer.move_to(datetime(2023, 12, 25)) # Monday + assert next_departuredate(["fri", "sat", "sun"]) == datetime(2023, 12, 29).date() diff --git a/tests/components/trafikverket_weatherstation/test_config_flow.py b/tests/components/trafikverket_weatherstation/test_config_flow.py index 36c30b33b53cf6..e55e04d8411156 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.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/trend/conftest.py b/tests/components/trend/conftest.py new file mode 100644 index 00000000000000..cff3831658a57f --- /dev/null +++ b/tests/components/trend/conftest.py @@ -0,0 +1,51 @@ +"""Fixtures for the trend component tests.""" +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from homeassistant.components.trend.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +ComponentSetup = Callable[[dict[str, Any]], Awaitable[None]] + + +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + return MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": "sensor.cpu_temp", + "invert": False, + "max_samples": 2.0, + "min_gradient": 0.0, + "sample_duration": 0.0, + }, + title="My trend", + ) + + +@pytest.fixture(name="setup_component") +async def mock_setup_component( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> ComponentSetup: + """Set up the trend component.""" + + async def _setup_func(component_params: dict[str, Any]) -> None: + config_entry.title = "test_trend_sensor" + config_entry.options = { + **config_entry.options, + **component_params, + "name": "test_trend_sensor", + "entity_id": "sensor.test_state", + } + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return _setup_func diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index cccf1add61b7f3..115bac5ed5dadc 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -1,421 +1,285 @@ """The test for the Trend sensor platform.""" from datetime import timedelta -from unittest.mock import patch +import logging +from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant import config as hass_config, setup -from homeassistant.components.trend.const import DOMAIN -from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN +from homeassistant import setup +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.setup import async_setup_component -from tests.common import ( - assert_setup_component, - get_fixture_path, - get_test_home_assistant, - mock_restore_cache, +from .conftest import ComponentSetup + +from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache + + +async def _setup_legacy_component(hass: HomeAssistant, params: dict[str, Any]) -> None: + """Set up the trend component the legacy way.""" + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": { + "test_trend_sensor": params, + }, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("states", "inverted", "expected_state"), + [ + (["1", "2"], False, STATE_ON), + (["2", "1"], False, STATE_OFF), + (["1", "2"], True, STATE_OFF), + (["2", "1"], True, STATE_ON), + ], + ids=["up", "down", "up inverted", "down inverted"], ) +async def test_basic_trend_setup_from_yaml( + hass: HomeAssistant, + states: list[str], + inverted: bool, + expected_state: str, +) -> None: + """Test trend with a basic setup.""" + await _setup_legacy_component( + hass, + { + "friendly_name": "Test state", + "entity_id": "sensor.cpu_temp", + "invert": inverted, + "max_samples": 2.0, + "min_gradient": 0.0, + "sample_duration": 0.0, + }, + ) + for state in states: + hass.states.async_set("sensor.cpu_temp", state) + await hass.async_block_till_done() -class TestTrendBinarySensor: - """Test the Trend sensor.""" + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state - hass = None - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() +@pytest.mark.parametrize( + ("states", "inverted", "expected_state"), + [ + (["1", "2"], False, STATE_ON), + (["2", "1"], False, STATE_OFF), + (["1", "2"], True, STATE_OFF), + (["2", "1"], True, STATE_ON), + ], + ids=["up", "down", "up inverted", "down inverted"], +) +async def test_basic_trend( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, + states: list[str], + inverted: bool, + expected_state: str, +) -> None: + """Test trend with a basic setup.""" + await setup_component( + { + "invert": inverted, + }, + ) - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() + for state in states: + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state + + +@pytest.mark.parametrize( + ("state_series", "inverted", "expected_states"), + [ + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + False, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + True, + [STATE_UNKNOWN, STATE_OFF, STATE_ON], + ), + ( + [[30, 20, 30, 10], [5], [30, 0, 45, 60]], + True, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ], + ids=["up", "up inverted", "down"], +) +async def test_using_trendline( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + setup_component: ComponentSetup, + state_series: list[list[str]], + inverted: bool, + expected_states: list[str], +) -> None: + """Test uptrend using multiple samples and trendline calculation.""" + await setup_component( + { + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + "invert": inverted, + }, + ) + + for idx, states in enumerate(state_series): + for state in states: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_states[idx] + + +@pytest.mark.parametrize( + ("attr_values", "expected_state"), + [ + (["1", "2"], STATE_ON), + (["2", "1"], STATE_OFF), + ], + ids=["up", "down"], +) +async def test_attribute_trend( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, + attr_values: list[str], + expected_state: str, +) -> None: + """Test attribute uptrend.""" + await setup_component( + { + "entity_id": "sensor.test_state", + "attribute": "attr", + }, + ) + + for attr in attr_values: + hass.states.async_set("sensor.test_state", "State", {"attr": attr}) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state + + +async def test_max_samples( + hass: HomeAssistant, config_entry: MockConfigEntry, setup_component: ComponentSetup +) -> None: + """Test that sample count is limited correctly.""" + await setup_component( + { + "max_samples": 3, + "min_gradient": -1, + }, + ) + + for val in [0, 1, 2, 3, 2, 1]: + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == "on" + assert state.attributes["sample_count"] == 3 + + +async def test_non_numeric( + hass: HomeAssistant, config_entry: MockConfigEntry, setup_component: ComponentSetup +) -> None: + """Test for non-numeric sensor.""" + await setup_component({"entity_id": "sensor.test_state"}) + + for val in ["Non", "Numeric"]: + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN - def test_up(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_up_using_trendline(self): - """Test up trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - } - }, - } - }, - ) - self.hass.block_till_done() - - now = dt_util.utcnow() - for val in [10, 0, 20, 30]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - # have to change state value, otherwise sample will lost - for val in [0, 30, 1, 0]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_down_using_trendline(self): - """Test down trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - now = dt_util.utcnow() - for val in [30, 20, 30, 10]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - for val in [30, 0, 45, 50]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_down(self): - """Test down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_up(self): - """Test up trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_down(self): - """Test down trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_up(self): - """Test attribute up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_down(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_max_samples(self): - """Test that sample count is limited correctly.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "max_samples": 3, - "min_gradient": -1, - } - }, - } - }, - ) - self.hass.block_till_done() - for val in [0, 1, 2, 3, 2, 1]: - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() +async def test_missing_attribute( + hass: HomeAssistant, config_entry: MockConfigEntry, setup_component: ComponentSetup +) -> None: + """Test for missing attribute.""" + await setup_component( + { + "attribute": "missing", + }, + ) + + for val in [1, 2]: + hass.states.async_set("sensor.test_state", "State", {"attr": val}) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - assert state.attributes["sample_count"] == 3 - def test_non_numeric(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, +async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: + """Test for invalid name.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", { "binary_sensor": { "platform": "trend", "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} + "test INVALID sensor": {"entity_id": "sensor.test_state"} }, } }, ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "Non") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "Numeric") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_missing_attribute(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, + assert hass.states.async_all("binary_sensor") == [] + + +async def test_invalid_sensor_does_not_create(hass: HomeAssistant) -> None: + """Test invalid sensor.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", { "binary_sensor": { "platform": "trend", "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "missing", - } + "test_trend_sensor": {"not_entity_id": "sensor.test_state"} }, } }, ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_invalid_name_does_not_create(self): - """Test invalid name.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test INVALID sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_invalid_sensor_does_not_create(self): - """Test invalid sensor.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_trend_sensor": {"not_entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_no_sensors_does_not_create(self): - """Test no sensors.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} - ) - assert self.hass.states.all("binary_sensor") == [] - - -async def test_reload(hass: HomeAssistant) -> None: - """Verify we can reload trend sensors.""" - hass.states.async_set("sensor.test_state", 1234) - - await setup.async_setup_component( - hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, - } - }, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 2 + assert hass.states.async_all("binary_sensor") == [] - assert hass.states.get("binary_sensor.test_trend_sensor") - yaml_path = get_fixture_path("configuration.yaml", "trend") - with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, +async def test_no_sensors_does_not_create(hass: HomeAssistant) -> None: + """Test no sensors.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 2 - - assert hass.states.get("binary_sensor.test_trend_sensor") is None - assert hass.states.get("binary_sensor.second_test_trend_sensor") + assert hass.states.async_all("binary_sensor") == [] @pytest.mark.parametrize( @@ -423,21 +287,65 @@ async def test_reload(hass: HomeAssistant) -> None: [("on", "on"), ("off", "off"), ("unknown", "unknown")], ) async def test_restore_state( - hass: HomeAssistant, saved_state: str, restored_state: str + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + setup_component: ComponentSetup, + saved_state: str, + restored_state: str, ) -> None: """Test we restore the trend state.""" mock_restore_cache(hass, (State("binary_sensor.test_trend_sensor", saved_state),)) - assert await setup.async_setup_component( - hass, - "binary_sensor", + await setup_component( { - "binary_sensor": { - "platform": "trend", - "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, - } + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, }, ) - await hass.async_block_till_done() + # restored sensor should match saved one + assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state + + # add not enough samples to trigger calculation + for val in [10, 20, 30, 40]: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + # state should match restored state as no calculation happened assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state + + # add more samples to trigger calculation + for val in [50, 60, 70, 80]: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + # sensor should detect an upwards trend and turn on + assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + +async def test_invalid_min_sample( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if error is logged when min_sample is larger than max_samples.""" + with caplog.at_level(logging.ERROR): + await _setup_legacy_component( + hass, + { + "entity_id": "sensor.test_state", + "max_samples": 25, + "min_samples": 30, + }, + ) + + record = caplog.records[0] + assert record.levelname == "ERROR" + assert ( + "Invalid config for 'binary_sensor' from integration 'trend': min_samples must " + "be smaller than or equal to max_samples" in record.message + ) diff --git a/tests/components/trend/test_config_flow.py b/tests/components/trend/test_config_flow.py new file mode 100644 index 00000000000000..e81d57ef9e182a --- /dev/null +++ b/tests/components/trend/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Trend config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.trend import async_setup_entry +from homeassistant.components.trend.const import 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.""" + + 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"], + {"name": "CPU Temperature rising", "entity_id": "sensor.cpu_temp"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + + # test step 2 of config flow: settings of trend sensor + with patch( + "homeassistant.components.trend.async_setup_entry", wraps=async_setup_entry + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "invert": False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "CPU Temperature rising" + assert result["data"] == {} + assert result["options"] == { + "entity_id": "sensor.cpu_temp", + "invert": False, + "name": "CPU Temperature rising", + } + + +async def test_options(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test options flow.""" + config_entry.add_to_hass(hass) + + 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"], + { + "min_samples": 30, + "max_samples": 50, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "min_samples": 30, + "max_samples": 50, + "entity_id": "sensor.cpu_temp", + "invert": False, + "min_gradient": 0.0, + "name": "My trend", + "sample_duration": 0.0, + } diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py new file mode 100644 index 00000000000000..47bcab2214d445 --- /dev/null +++ b/tests/components/trend/test_init.py @@ -0,0 +1,50 @@ +"""Test the Trend integration.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.trend.conftest import ComponentSetup + + +async def test_setup_and_remove_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test setting up and removing a config entry.""" + registry = er.async_get(hass) + trend_entity_id = "binary_sensor.my_trend" + + # Set up the config entry + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the entity is registered in the entity registry + assert registry.async_get(trend_entity_id) is not None + + # Remove the config entry + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state and entity registry entry are removed + assert hass.states.get(trend_entity_id) is None + assert registry.async_get(trend_entity_id) is None + + +async def test_reload_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, +) -> None: + """Test config entry reload.""" + await setup_component({}) + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.config_entries.async_update_entry( + config_entry, data={**config_entry.data, "max_samples": 4.0} + ) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data == {**config_entry.data, "max_samples": 4.0} diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 71be6b3bb11f5a..d56542b2a57b12 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import ffmpeg, tts @@ -78,6 +79,7 @@ async def test_config_entry_unload( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts_entity: MockTTSEntity, + freezer: FrozenDateTimeFactory, ) -> None: """Test we can unload config entry.""" entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" @@ -93,26 +95,24 @@ async def test_config_entry_unload( calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - tts.DOMAIN, - "speak", - { - ATTR_ENTITY_ID: entity_id, - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - blocking=True, - ) - assert len(calls) == 1 + freezer.move_to(now) + await hass.services.async_call( + tts.DOMAIN, + "speak", + { + ATTR_ENTITY_ID: entity_id, + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 - assert ( - await retrieve_media( - hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] - ) - == HTTPStatus.OK - ) - await hass.async_block_till_done() + 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) assert state is not None @@ -150,7 +150,7 @@ async def test_restore_state( async def test_setup_component(hass: HomeAssistant, setup: str) -> None: """Set up a TTS platform with defaults.""" assert hass.services.has_service(tts.DOMAIN, "clear_cache") - assert f"{tts.DOMAIN}.test" in hass.config.components + assert f"test.{tts.DOMAIN}" in hass.config.components @pytest.mark.parametrize("init_tts_cache_dir_side_effect", [OSError(2, "No access")]) @@ -1406,7 +1406,9 @@ def test_resolve_engine(hass: HomeAssistant, setup: str, engine_id: str) -> None with patch.dict( hass.data[tts.DATA_TTS_MANAGER].providers, {}, clear=True - ), patch.dict(hass.data[tts.DOMAIN]._platforms, {}, clear=True): + ), patch.dict(hass.data[tts.DOMAIN]._platforms, {}, clear=True), patch.dict( + hass.data[tts.DOMAIN]._entities, {}, clear=True + ): assert tts.async_resolve_engine(hass, None) is None with patch.dict(hass.data[tts.DATA_TTS_MANAGER].providers, {"cloud": object()}): diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index 0630114da9035f..f8345683d4ac6e 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -13,15 +13,13 @@ CONF_ACCESS_SECRET, CONF_APP_TYPE, CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, CONF_ENDPOINT, - CONF_PASSWORD, - CONF_USERNAME, DOMAIN, SMARTLIFE_APP, TUYA_COUNTRIES, TUYA_SMART_APP, ) +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant MOCK_SMART_HOME_PROJECT_TYPE = 0 diff --git a/tests/components/twentemilieu/snapshots/test_config_flow.ambr b/tests/components/twentemilieu/snapshots/test_config_flow.ambr index 7acb466d997772..00b960620520ec 100644 --- a/tests/components/twentemilieu/snapshots/test_config_flow.ambr +++ b/tests/components/twentemilieu/snapshots/test_config_flow.ambr @@ -15,6 +15,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'twentemilieu', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -27,6 +28,7 @@ 'disabled_by': None, 'domain': 'twentemilieu', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -57,6 +59,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'twentemilieu', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -69,6 +72,7 @@ 'disabled_by': None, 'domain': 'twentemilieu', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index bd51ac5d7cde86..4b1411e9223d7f 100644 --- a/tests/components/twinkly/__init__.py +++ b/tests/components/twinkly/__init__.py @@ -1,4 +1,4 @@ -"""Constants and mock for the twkinly component tests.""" +"""Constants and mock for the twinkly component tests.""" from aiohttp.client_exceptions import ClientConnectionError diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 7a7dc2557ef41d..2a10154c3dacf8 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -30,6 +30,7 @@ 'disabled_by': None, 'domain': 'twinkly', 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 2d335c69923142..a65a2a2d96383f 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -3,13 +3,8 @@ from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.twinkly.const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, - DOMAIN as TWINKLY_DOMAIN, -) -from homeassistant.const import CONF_MODEL +from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from . import TEST_MODEL, TEST_NAME, ClientMock diff --git a/tests/components/twinkly/test_init.py b/tests/components/twinkly/test_init.py index f2049f9b513899..33f24a31d8f062 100644 --- a/tests/components/twinkly/test_init.py +++ b/tests/components/twinkly/test_init.py @@ -3,14 +3,9 @@ from unittest.mock import patch from uuid import uuid4 -from homeassistant.components.twinkly.const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, - DOMAIN as TWINKLY_DOMAIN, -) +from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_MODEL +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from . import TEST_HOST, TEST_MODEL, TEST_NAME_ORIGINAL, ClientMock diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index bcb40f22d08a27..e3b8b499c8ed6c 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -4,13 +4,8 @@ from unittest.mock import patch from homeassistant.components.light import ATTR_BRIGHTNESS, LightEntityFeature -from homeassistant.components.twinkly.const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, - DOMAIN as TWINKLY_DOMAIN, -) -from homeassistant.const import CONF_MODEL +from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 30a1b3e08ffe1b..8e6dce71160a77 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 9d4bde2d016f03..268f4e8493a411 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 cbff868d9a67d4..34d43129a947ab 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,9 +1,8 @@ """The tests for the UniFi Network device tracker platform.""" from datetime import timedelta -from unittest.mock import patch from aiounifi.models.message import MessageKey -from freezegun.api import FrozenDateTimeFactory +from freezegun.api import FrozenDateTimeFactory, freeze_time from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -72,7 +71,7 @@ async def test_tracked_wireless_clients( # Change time to mark client as away new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -293,6 +292,7 @@ async def test_tracked_wireless_clients_event_source( async def test_tracked_devices( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, mock_device_registry, ) -> None: @@ -351,9 +351,9 @@ async def test_tracked_devices( # Change of time can mark device not_home outside of expected reporting interval new_time = dt_util.utcnow() + timedelta(seconds=90) - with patch("homeassistant.util.dt.utcnow", return_value=new_time): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_NOT_HOME assert hass.states.get("device_tracker.device_2").state == STATE_HOME @@ -712,7 +712,7 @@ async def test_option_ssid_filter( await hass.async_block_till_done() new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -740,7 +740,7 @@ async def test_option_ssid_filter( # Time pass to mark client as away new_time += controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -759,7 +759,7 @@ async def test_option_ssid_filter( await hass.async_block_till_done() new_time += controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -808,7 +808,7 @@ async def test_wireless_client_go_wired_issue( # Pass time new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -877,7 +877,7 @@ async def test_option_ignore_wired_bug( # pass time new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -930,6 +930,7 @@ async def test_restoring_client( config_entry = config_entries.ConfigEntry( version=1, + minor_version=1, domain=UNIFI_DOMAIN, title="Mock Title", data=ENTRY_CONFIG, @@ -939,13 +940,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_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 638e79ae64961d..127b9b79c2b729 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -129,6 +129,7 @@ async def test_entry_diagnostics( "disabled_by": None, "domain": "unifi", "entry_id": "1", + "minor_version": 1, "options": { "allow_bandwidth_sensors": True, "allow_uptime_sensors": True, diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index f4366b98fc3a4b..6eb6c05209c888 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -3,7 +3,9 @@ from datetime import datetime, timedelta from unittest.mock import patch +from aiounifi.models.device import DeviceState from aiounifi.models.message import MessageKey +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -19,6 +21,7 @@ CONF_ALLOW_UPTIME_SENSORS, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, + DEVICE_STATES, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory @@ -429,6 +432,7 @@ async def test_bandwidth_sensors( async def test_uptime_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, entity_registry_enabled_by_default: None, initial_uptime, @@ -450,13 +454,13 @@ async def test_uptime_sensors( } now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.now", return_value=now): - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options=options, - clients_response=[uptime_client], - ) + freezer.move_to(now) + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + options=options, + clients_response=[uptime_client], + ) assert len(hass.states.async_all()) == 2 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 @@ -582,7 +586,7 @@ async def test_poe_port_switches( ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get("sensor.mock_name_port_1_poe_power") @@ -805,8 +809,8 @@ async def test_outlet_power_readings( """Test the outlet power reporting on PDU devices.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1]) - assert len(hass.states.async_all()) == 10 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + assert len(hass.states.async_all()) == 11 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 5 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get(f"sensor.{entity_id}") @@ -854,7 +858,7 @@ async def test_device_uptime( now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" ent_reg = er.async_get(hass) @@ -910,7 +914,7 @@ async def test_device_temperature( } await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 assert hass.states.get("sensor.device_temperature").state == "30" ent_reg = er.async_get(hass) @@ -923,3 +927,43 @@ async def test_device_temperature( device["general_temperature"] = 60 mock_unifi_websocket(message=MessageKey.DEVICE, data=device) assert hass.states.get("sensor.device_temperature").state == "60" + + +async def test_device_state( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Verify that state sensors are working as expected.""" + device = { + "board_rev": 3, + "device_id": "mock-id", + "general_temperature": 30, + "has_fan": True, + "has_temperature": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "uptime": 60, + "version": "4.0.42.10433", + } + + await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + + ent_reg = er.async_get(hass) + assert ( + ent_reg.async_get("sensor.device_state").entity_category + is EntityCategory.DIAGNOSTIC + ) + + for i in list(map(int, DeviceState)): + device["state"] = i + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + assert hass.states.get("sensor.device_state").state == DEVICE_STATES[i] diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index cfcfbe6c3ed072..6a9e58b6f760f4 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -5,6 +5,7 @@ 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.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,71 @@ 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, + minor_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/unifi/test_update.py b/tests/components/unifi/test_update.py index 4f7a3dfe11d8cb..a9fe3fdae7ce9c 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -117,7 +117,7 @@ async def test_device_updates( # Simulate update finished - device_1["state"] = "0" + device_1["state"] = 0 device_1["version"] = "4.3.17.11279" device_1["upgradable"] = False del device_1["upgrade_to_firmware"] diff --git a/tests/components/unifi_direct/__init__.py b/tests/components/unifi_direct/__init__.py deleted file mode 100644 index 7f8d0fa29f779f..00000000000000 --- a/tests/components/unifi_direct/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the unifi_direct component.""" diff --git a/tests/components/unifi_direct/fixtures/data.txt b/tests/components/unifi_direct/fixtures/data.txt deleted file mode 100644 index fcb58070fcca62..00000000000000 --- a/tests/components/unifi_direct/fixtures/data.txt +++ /dev/null @@ -1 +0,0 @@ -b'mca-dump | tr -d "\r\n> "\r\n{ "board_rev": 16, "bootrom_version": "unifi-v1.6.7.249-gb74e0282", "cfgversion": "63b505a1c328fd9c", "country_code": 840, "default": false, "discovery_response": true, "fw_caps": 855, "guest_token": "E6BAE04FD72C", "has_eth1": false, "has_speaker": false, "hostname": "UBNT", "if_table": [ { "full_duplex": true, "ip": "0.0.0.0", "mac": "80:2a:a8:56:34:12", "name": "eth0", "netmask": "0.0.0.0", "num_port": 1, "rx_bytes": 3879332085, "rx_dropped": 0, "rx_errors": 0, "rx_multicast": 0, "rx_packets": 4093520, "speed": 1000, "tx_bytes": 1745140940, "tx_dropped": 0, "tx_errors": 0, "tx_packets": 3105586, "up": true } ], "inform_url": "?", "ip": "192.168.1.2", "isolated": false, "last_error": "", "locating": false, "mac": "80:2a:a8:56:34:12", "model": "U7LR", "model_display": "UAP-AC-LR", "netmask": "255.255.255.0", "port_table": [ { "media": "GE", "poe_caps": 0, "port_idx": 0, "port_poe": false } ], "radio_table": [ { "athstats": { "ast_ath_reset": 0, "ast_be_xmit": 1098121, "ast_cst": 225, "ast_deadqueue_reset": 0, "ast_fullqueue_stop": 0, "ast_txto": 151, "cu_self_rx": 8, "cu_self_tx": 4, "cu_total": 12, "n_rx_aggr": 3915695, "n_rx_pkts": 6518082, "n_tx_bawadv": 1205430, "n_tx_bawretries": 70257, "n_tx_pkts": 1813368, "n_tx_queue": 1024366, "n_tx_retries": 70273, "n_tx_xretries": 897, "n_txaggr_compgood": 616173, "n_txaggr_compretries": 71170, "n_txaggr_compxretry": 0, "n_txaggr_prepends": 21240, "name": "wifi0" }, "builtin_ant_gain": 0, "builtin_antenna": true, "max_txpower": 24, "min_txpower": 6, "name": "wifi0", "nss": 3, "radio": "ng", "scan_table": [ { "age": 2, "bssid": "28:56:5a:34:23:12", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "someones_wifi", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 8, "rssi_age": 2, "security": "secured" }, { "age": 37, "bssid": "00:60:0f:45:34:12", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 37, "security": "secured" }, { "age": 29, "bssid": "b0:93:5b:7a:35:23", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "ARRIS-CB55", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 29, "security": "secured" }, { "age": 0, "bssid": "e0:46:9a:e1:ea:7d", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "Darjeeling", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 9, "rssi_age": 0, "security": "secured" }, { "age": 1, "bssid": "00:60:0f:e1:ea:7e", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 1, "security": "secured" }, { "age": 0, "bssid": "7c:d1:c3:cd:e5:f4", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "Chris\'s Wi-Fi Network", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 17, "rssi_age": 0, "security": "secured" } ] }, { "athstats": { "ast_ath_reset": 14, "ast_be_xmit": 1097310, "ast_cst": 0, "ast_deadqueue_reset": 41, "ast_fullqueue_stop": 0, "ast_txto": 0, "cu_self_rx": 0, "cu_self_tx": 0, "cu_total": 0, "n_rx_aggr": 106804, "n_rx_pkts": 2453041, "n_tx_bawadv": 557298, "n_tx_bawretries": 0, "n_tx_pkts": 1080, "n_tx_queue": 0, "n_tx_retries": 1, "n_tx_xretries": 44046, "n_txaggr_compgood": 0, "n_txaggr_compretries": 0, "n_txaggr_compxretry": 0, "n_txaggr_prepends": 0, "name": "wifi1" }, "builtin_ant_gain": 0, "builtin_antenna": true, "has_dfs": true, "has_fccdfs": true, "is_11ac": true, "max_txpower": 22, "min_txpower": 4, "name": "wifi1", "nss": 2, "radio": "na", "scan_table": [] } ], "required_version": "3.4.1", "selfrun_beacon": false, "serial": "802AA896363C", "spectrum_scanning": false, "ssh_session_table": [], "state": 0, "stream_token": "", "sys_stats": { "loadavg_1": "0.03", "loadavg_15": "0.06", "loadavg_5": "0.06", "mem_buffer": 0, "mem_total": 129310720, "mem_used": 75800576 }, "system-stats": { "cpu": "8.4", "mem": "58.6", "uptime": "112391" }, "time": 1508795154, "uplink": "eth0", "uptime": 112391, "vap_table": [ { "bssid": "80:2a:a8:97:36:3c", "ccq": 914, "channel": 11, "essid": "220", "id": "55b19c7e50e4e11e798e84c7", "name": "ath0", "num_sta": 20, "radio": "ng", "rx_bytes": 1155345354, "rx_crypts": 5491, "rx_dropped": 5540, "rx_errors": 5540, "rx_frags": 0, "rx_nwids": 647001, "rx_packets": 1840967, "sta_table": [ { "auth_time": 4294967206, "authorized": true, "ccq": 991, "dhcpend_time": 660, "dhcpstart_time": 660, "hostname": "amazon-device", "idletime": 0, "ip": "192.168.1.45", "is_11n": true, "mac": "44:65:0d:12:34:56", "noise": -114, "rssi": 59, "rx_bytes": 1176121, "rx_mcast": 0, "rx_packets": 20927, "rx_rate": 24000, "rx_retries": 0, "signal": -55, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 364495, "tx_packets": 2183, "tx_power": 48, "tx_rate": 72222, "tx_retries": 589, "uptime": 7031, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 290, "dhcpstart_time": 290, "hostname": "iPhone", "idletime": 9, "ip": "192.168.1.209", "is_11n": true, "mac": "98:00:c6:56:34:12", "noise": -114, "rssi": 40, "rx_bytes": 5862172, "rx_mcast": 0, "rx_packets": 30977, "rx_rate": 24000, "rx_retries": 0, "signal": -74, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 31707361, "tx_packets": 27775, "tx_power": 48, "tx_rate": 140637, "tx_retries": 1213, "uptime": 15556, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 630, "dhcpstart_time": 630, "hostname": "android", "idletime": 0, "ip": "192.168.1.10", "is_11n": true, "mac": "b4:79:a7:45:34:12", "noise": -114, "rssi": 60, "rx_bytes": 13694423, "rx_mcast": 0, "rx_packets": 110909, "rx_rate": 1000, "rx_retries": 0, "signal": -54, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 7988429, "tx_packets": 28863, "tx_power": 48, "tx_rate": 72222, "tx_retries": 1254, "uptime": 19052, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 4480, "dhcpstart_time": 4480, "hostname": "wink", "idletime": 0, "ip": "192.168.1.3", "is_11n": true, "mac": "b4:79:a7:56:34:12", "noise": -114, "rssi": 38, "rx_bytes": 18705870, "rx_mcast": 0, "rx_packets": 78794, "rx_rate": 72109, "rx_retries": 0, "signal": -76, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 4416534, "tx_packets": 58304, "tx_power": 48, "tx_rate": 72222, "tx_retries": 1978, "uptime": 51648, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 981, "dhcpend_time": 1530, "dhcpstart_time": 1530, "hostname": "Chromecast", "idletime": 0, "ip": "192.168.1.30", "is_11n": true, "mac": "80:d2:1d:56:34:12", "noise": -114, "rssi": 37, "rx_bytes": 29377621, "rx_mcast": 0, "rx_packets": 105806, "rx_rate": 72109, "rx_retries": 0, "signal": -77, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 122681792, "tx_packets": 145339, "tx_power": 48, "tx_rate": 72222, "tx_retries": 2980, "uptime": 53658, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 370, "dhcpstart_time": 360, "idletime": 2, "ip": "192.168.1.51", "is_11n": false, "mac": "48:02:2d:56:34:12", "noise": -114, "rssi": 56, "rx_bytes": 48148926, "rx_mcast": 0, "rx_packets": 59462, "rx_rate": 1000, "rx_retries": 0, "signal": -58, "state": 16391, "state_ht": false, "state_pwrmgt": false, "tx_bytes": 7075470, "tx_packets": 33047, "tx_power": 48, "tx_rate": 54000, "tx_retries": 2833, "uptime": 63850, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 971, "dhcpend_time": 30, "dhcpstart_time": 30, "hostname": "ESP_1C2F8D", "idletime": 0, "ip": "192.168.1.54", "is_11n": true, "mac": "a0:20:a6:45:35:12", "noise": -114, "rssi": 51, "rx_bytes": 4684699, "rx_mcast": 0, "rx_packets": 137798, "rx_rate": 2000, "rx_retries": 0, "signal": -63, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 355735, "tx_packets": 6977, "tx_power": 48, "tx_rate": 72222, "tx_retries": 590, "uptime": 78427, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 220, "dhcpstart_time": 220, "hostname": "HF-LPB100-ZJ200", "idletime": 2, "ip": "192.168.1.53", "is_11n": true, "mac": "f0:fe:6b:56:34:12", "noise": -114, "rssi": 29, "rx_bytes": 1415840, "rx_mcast": 0, "rx_packets": 22821, "rx_rate": 1000, "rx_retries": 0, "signal": -85, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 402439, "tx_packets": 7779, "tx_power": 48, "tx_rate": 72222, "tx_retries": 891, "uptime": 111944, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 1620, "dhcpstart_time": 1620, "idletime": 0, "ip": "192.168.1.33", "is_11n": false, "mac": "94:10:3e:45:34:12", "noise": -114, "rssi": 48, "rx_bytes": 47843953, "rx_mcast": 0, "rx_packets": 79456, "rx_rate": 54000, "rx_retries": 0, "signal": -66, "state": 16391, "state_ht": false, "state_pwrmgt": false, "tx_bytes": 4357955, "tx_packets": 60958, "tx_power": 48, "tx_rate": 54000, "tx_retries": 4598, "uptime": 112316, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 540, "dhcpstart_time": 540, "hostname": "amazon-device", "idletime": 0, "ip": "192.168.1.46", "is_11n": true, "mac": "ac:63:be:56:34:12", "noise": -114, "rssi": 30, "rx_bytes": 14607810, "rx_mcast": 0, "rx_packets": 326158, "rx_rate": 24000, "rx_retries": 0, "signal": -84, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 3238319, "tx_packets": 25605, "tx_power": 48, "tx_rate": 72222, "tx_retries": 2465, "uptime": 112364, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 941, "dhcpend_time": 1060, "dhcpstart_time": 1060, "hostname": "Broadlink_RMMINI-56-34-12", "idletime": 12, "ip": "192.168.1.52", "is_11n": true, "mac": "34:ea:34:56:34:12", "noise": -114, "rssi": 43, "rx_bytes": 625268, "rx_mcast": 0, "rx_packets": 4711, "rx_rate": 65000, "rx_retries": 0, "signal": -71, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 420763, "tx_packets": 4620, "tx_power": 48, "tx_rate": 65000, "tx_retries": 783, "uptime": 112368, "vlan_id": 0 }, { "auth_time": 4294967256, "authorized": true, "ccq": 930, "dhcpend_time": 3360, "dhcpstart_time": 3360, "hostname": "garage", "idletime": 2, "ip": "192.168.1.28", "is_11n": true, "mac": "00:13:ef:45:34:12", "noise": -114, "rssi": 28, "rx_bytes": 11639474, "rx_mcast": 0, "rx_packets": 102103, "rx_rate": 24000, "rx_retries": 0, "signal": -86, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 6282728, "tx_packets": 85279, "tx_power": 48, "tx_rate": 58500, "tx_retries": 21185, "uptime": 112369, "vlan_id": 0 }, { "auth_time": 4294967286, "authorized": true, "ccq": 991, "dhcpend_time": 30, "dhcpstart_time": 30, "hostname": "keurig", "idletime": 0, "ip": "192.168.1.48", "is_11n": true, "mac": "18:fe:34:56:34:12", "noise": -114, "rssi": 52, "rx_bytes": 17781940, "rx_mcast": 0, "rx_packets": 432172, "rx_rate": 6000, "rx_retries": 0, "signal": -62, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 4143184, "tx_packets": 53751, "tx_power": 48, "tx_rate": 72222, "tx_retries": 3781, "uptime": 112369, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 940, "dhcpend_time": 50, "dhcpstart_time": 50, "hostname": "freezer", "idletime": 0, "ip": "192.168.1.26", "is_11n": true, "mac": "5c:cf:7f:07:5a:a4", "noise": -114, "rssi": 47, "rx_bytes": 13613265, "rx_mcast": 0, "rx_packets": 411785, "rx_rate": 2000, "rx_retries": 0, "signal": -67, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 1411127, "tx_packets": 17492, "tx_power": 48, "tx_rate": 65000, "tx_retries": 5869, "uptime": 112370, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 778, "dhcpend_time": 50, "dhcpstart_time": 50, "hostname": "fan", "idletime": 0, "ip": "192.168.1.34", "is_11n": true, "mac": "5c:cf:7f:02:09:4e", "noise": -114, "rssi": 45, "rx_bytes": 15377230, "rx_mcast": 0, "rx_packets": 417435, "rx_rate": 6000, "rx_retries": 0, "signal": -69, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 2974258, "tx_packets": 36175, "tx_power": 48, "tx_rate": 58500, "tx_retries": 18552, "uptime": 112372, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 991, "dhcpend_time": 1070, "dhcpstart_time": 1070, "hostname": "Broadlink_RMPROPLUS-45-34-12", "idletime": 1, "ip": "192.168.1.9", "is_11n": true, "mac": "b4:43:0d:45:56:56", "noise": -114, "rssi": 57, "rx_bytes": 1792908, "rx_mcast": 0, "rx_packets": 8528, "rx_rate": 72109, "rx_retries": 0, "signal": -57, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 770834, "tx_packets": 8443, "tx_power": 48, "tx_rate": 65000, "tx_retries": 5258, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 210, "dhcpstart_time": 210, "idletime": 49, "ip": "192.168.1.40", "is_11n": true, "mac": "0c:2a:69:02:3e:3b", "noise": -114, "rssi": 36, "rx_bytes": 427418, "rx_mcast": 0, "rx_packets": 2824, "rx_rate": 65000, "rx_retries": 0, "signal": -78, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 176039, "tx_packets": 2872, "tx_power": 48, "tx_rate": 65000, "tx_retries": 87, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 991, "dhcpend_time": 5030, "dhcpstart_time": 5030, "hostname": "HP2C27D78D9F3E", "idletime": 268, "ip": "192.168.1.44", "is_11n": true, "mac": "2c:27:d7:8d:9f:3e", "noise": -114, "rssi": 41, "rx_bytes": 172927, "rx_mcast": 0, "rx_packets": 781, "rx_rate": 72109, "rx_retries": 0, "signal": -73, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 41924, "tx_packets": 453, "tx_power": 48, "tx_rate": 66610, "tx_retries": 66, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 110, "dhcpstart_time": 110, "idletime": 4, "ip": "192.168.1.55", "is_11n": true, "mac": "0c:2a:69:04:e6:ac", "noise": -114, "rssi": 51, "rx_bytes": 300741, "rx_mcast": 0, "rx_packets": 2443, "rx_rate": 65000, "rx_retries": 0, "signal": -63, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 159980, "tx_packets": 2526, "tx_power": 48, "tx_rate": 65000, "tx_retries": 47, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967256, "authorized": true, "ccq": 991, "dhcpend_time": 1570, "dhcpstart_time": 1560, "idletime": 1, "ip": "192.168.1.37", "is_11n": true, "mac": "0c:2a:69:03:df:37", "noise": -114, "rssi": 42, "rx_bytes": 304567, "rx_mcast": 0, "rx_packets": 2468, "rx_rate": 65000, "rx_retries": 0, "signal": -72, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 164382, "tx_packets": 2553, "tx_power": 48, "tx_rate": 65000, "tx_retries": 48, "uptime": 112373, "vlan_id": 0 } ], "state": "RUN", "tx_bytes": 1190129336, "tx_dropped": 7, "tx_errors": 0, "tx_packets": 1907093, "tx_power": 24, "tx_retries": 29927, "up": true, "usage": "user" }, { "bssid": "ff:ff:ff:ff:ff:ff", "ccq": 914, "channel": 157, "essid": "", "extchannel": 1, "id": "user", "name": "ath1", "num_sta": 0, "radio": "na", "rx_bytes": 0, "rx_crypts": 0, "rx_dropped": 0, "rx_errors": 0, "rx_frags": 0, "rx_nwids": 0, "rx_packets": 0, "sta_table": [], "state": "INIT", "tx_bytes": 0, "tx_dropped": 0, "tx_errors": 0, "tx_packets": 0, "tx_power": 22, "tx_retries": 0, "up": false, "usage": "uplink" }, { "bssid": "82:2a:a8:98:36:3c", "ccq": 482, "channel": 157, "essid": "220 5ghz", "extchannel": 1, "id": "55b19c7e50e4e11e798e84c7", "name": "ath2", "num_sta": 3, "radio": "na", "rx_bytes": 250435644, "rx_crypts": 4071, "rx_dropped": 4071, "rx_errors": 4071, "rx_frags": 0, "rx_nwids": 6660, "rx_packets": 1123263, "sta_table": [ { "auth_time": 4294967246, "authorized": true, "ccq": 631, "dhcpend_time": 190, "dhcpstart_time": 190, "hostname": "android-f4aaefc31d5d2f78", "idletime": 26, "ip": "192.168.1.15", "is_11a": true, "is_11ac": true, "is_11n": true, "mac": "c0:ee:fb:24:ef:a0", "noise": -105, "rssi": 16, "rx_bytes": 3188995, "rx_mcast": 0, "rx_packets": 37243, "rx_rate": 81000, "rx_retries": 0, "signal": -89, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 89051905, "tx_packets": 64756, "tx_power": 44, "tx_rate": 108000, "tx_retries": 0, "uptime": 5494, "vlan_id": 0 }, { "auth_time": 4294967286, "authorized": true, "ccq": 333, "dhcpend_time": 10, "dhcpstart_time": 10, "hostname": "mac_book_air", "idletime": 1, "ip": "192.168.1.12", "is_11a": true, "is_11n": true, "mac": "00:88:65:56:34:12", "noise": -105, "rssi": 52, "rx_bytes": 106902966, "rx_mcast": 0, "rx_packets": 270845, "rx_rate": 300000, "rx_retries": 0, "signal": -53, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 289588466, "tx_packets": 339466, "tx_power": 44, "tx_rate": 300000, "tx_retries": 0, "uptime": 15312, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 333, "dhcpend_time": 160, "dhcpstart_time": 160, "hostname": "Chromecast", "idletime": 0, "ip": "192.168.1.29", "is_11a": true, "is_11ac": true, "is_11n": true, "mac": "f4:f5:d8:11:57:6a", "noise": -105, "rssi": 40, "rx_bytes": 50958412, "rx_mcast": 0, "rx_packets": 339563, "rx_rate": 200000, "rx_retries": 0, "signal": -65, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 1186178689, "tx_packets": 890384, "tx_power": 44, "tx_rate": 150000, "tx_retries": 0, "uptime": 56493, "vlan_id": 0 } ], "state": "RUN", "tx_bytes": 2766849222, "tx_dropped": 119, "tx_errors": 23508, "tx_packets": 2247859, "tx_power": 22, "tx_retries": 0, "up": true, "usage": "user" } ], "version": "3.7.58.6385", "wifi_caps": 1909}' \ No newline at end of file diff --git a/tests/components/unifi_direct/test_device_tracker.py b/tests/components/unifi_direct/test_device_tracker.py deleted file mode 100644 index cfb1c7e92bca8a..00000000000000 --- a/tests/components/unifi_direct/test_device_tracker.py +++ /dev/null @@ -1,178 +0,0 @@ -"""The tests for the Unifi direct device tracker platform.""" -from datetime import timedelta -import os -from unittest.mock import MagicMock, call, patch - -import pytest -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - CONF_CONSIDER_HOME, - CONF_NEW_DEVICE_DEFAULTS, - CONF_TRACK_NEW, -) -from homeassistant.components.device_tracker.legacy import YAML_DEVICES -from homeassistant.components.unifi_direct.device_tracker import ( - CONF_PORT, - DOMAIN, - PLATFORM_SCHEMA, - UnifiDeviceScanner, - _response_to_json, - get_scanner, -) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import assert_setup_component, load_fixture, mock_component - -scanner_path = "homeassistant.components.unifi_direct.device_tracker.UnifiDeviceScanner" - - -@pytest.fixture(autouse=True) -def setup_comp(hass): - """Initialize components.""" - mock_component(hass, "zone") - yaml_devices = hass.config.path(YAML_DEVICES) - yield - if os.path.isfile(yaml_devices): - os.remove(yaml_devices) - - -@patch(scanner_path, return_value=MagicMock(spec=UnifiDeviceScanner)) -async def test_get_scanner(unifi_mock, hass: HomeAssistant) -> None: - """Test creating an Unifi direct scanner with a password.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - CONF_NEW_DEVICE_DEFAULTS: {CONF_TRACK_NEW: True}, - } - } - - with assert_setup_component(1, DOMAIN): - assert await async_setup_component(hass, DOMAIN, conf_dict) - - conf_dict[DOMAIN][CONF_PORT] = 22 - assert unifi_mock.call_args == call(conf_dict[DOMAIN]) - - -@patch("pexpect.pxssh.pxssh") -async def test_get_device_name(mock_ssh, hass: HomeAssistant) -> None: - """Testing MAC matching.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - } - } - mock_ssh.return_value.before = load_fixture("data.txt", "unifi_direct") - scanner = get_scanner(hass, conf_dict) - devices = scanner.scan_devices() - assert len(devices) == 23 - assert scanner.get_device_name("98:00:c6:56:34:12") == "iPhone" - assert scanner.get_device_name("98:00:C6:56:34:12") == "iPhone" - - -@patch("pexpect.pxssh.pxssh.logout") -@patch("pexpect.pxssh.pxssh.login") -async def test_failed_to_log_in(mock_login, mock_logout, hass: HomeAssistant) -> None: - """Testing exception at login results in False.""" - from pexpect import exceptions - - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - } - } - - mock_login.side_effect = exceptions.EOF("Test") - scanner = get_scanner(hass, conf_dict) - assert not scanner - - -@patch("pexpect.pxssh.pxssh.logout") -@patch("pexpect.pxssh.pxssh.login", autospec=True) -@patch("pexpect.pxssh.pxssh.prompt") -@patch("pexpect.pxssh.pxssh.sendline") -async def test_to_get_update( - mock_sendline, mock_prompt, mock_login, mock_logout, hass: HomeAssistant -) -> None: - """Testing exception in get_update matching.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - } - } - - scanner = get_scanner(hass, conf_dict) - # mock_sendline.side_effect = AssertionError("Test") - mock_prompt.side_effect = AssertionError("Test") - devices = scanner._get_update() - assert devices is None - - -def test_good_response_parses(hass: HomeAssistant) -> None: - """Test that the response form the AP parses to JSON correctly.""" - response = _response_to_json(load_fixture("data.txt", "unifi_direct")) - assert response != {} - - -def test_bad_response_returns_none(hass: HomeAssistant) -> None: - """Test that a bad response form the AP parses to JSON correctly.""" - assert _response_to_json("{(}") == {} - - -def test_config_error() -> None: - """Test for configuration errors.""" - with pytest.raises(vol.Invalid): - PLATFORM_SCHEMA( - { - # no username - CONF_PASSWORD: "password", - CONF_PLATFORM: DOMAIN, - CONF_HOST: "myhost", - "port": 123, - } - ) - with pytest.raises(vol.Invalid): - PLATFORM_SCHEMA( - { - # no password - CONF_USERNAME: "foo", - CONF_PLATFORM: DOMAIN, - CONF_HOST: "myhost", - "port": 123, - } - ) - with pytest.raises(vol.Invalid): - PLATFORM_SCHEMA( - { - CONF_PLATFORM: DOMAIN, - CONF_USERNAME: "foo", - CONF_PASSWORD: "password", - CONF_HOST: "myhost", - "port": "foo", # bad port! - } - ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index b8932b99e2cd49..17db53d05ec5a1 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -38,6 +38,7 @@ and d.name != "Detections: License Plate" and d.name != "Detections: Smoke/CO" and d.name != "SSH Enabled" + and d.name != "Color Night Vision" ] CAMERA_SWITCHES_NO_EXTRA = [ d for d in CAMERA_SWITCHES_BASIC if d.name not in ("High FPS", "Privacy Mode") diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index e31cab59358e89..60196e6fe24e1a 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -159,7 +159,7 @@ def is_volume_muted(self): @property def supported_features(self): """Flag media player features that are supported.""" - return self._supported_features + return MediaPlayerEntityFeature(self._supported_features) @property def media_image_url(self): diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 73f98c9e2db06c..92e63af4b6f9a7 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -128,6 +128,7 @@ async def test_update(hass: HomeAssistant) -> None: update.entity_description = UpdateEntityDescription(key="F5 - Its very refreshing") assert update.device_class is None assert update.entity_category is EntityCategory.CONFIG + del update.device_class update.entity_description = UpdateEntityDescription( key="F5 - Its very refreshing", device_class=UpdateDeviceClass.FIRMWARE, @@ -864,3 +865,23 @@ async def async_setup_entry_platform( state = hass.states.get(entity4.entity_id) assert state assert expected.items() <= state.attributes.items() + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockUpdateEntity(UpdateEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockUpdateEntity() + assert entity.supported_features_compat is UpdateEntityFeature(1) + assert "MockUpdateEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "UpdateEntityFeature.INSTALL" in caplog.text + caplog.clear() + assert entity.supported_features_compat is UpdateEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 0952b14303d0f0..db166144925d2a 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_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 @@ 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 register_callback(hass, callback, match_dict): 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 4c69b6f6875c2b..7c542e33c9d981 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 e775757cb1ff8b..d1d3dfa6c35055 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/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr index ac4b7396839fb8..3e5b492f871a1a 100644 --- a/tests/components/uptime/snapshots/test_config_flow.ambr +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -10,6 +10,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'uptime', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -18,6 +19,7 @@ 'disabled_by': None, 'domain': 'uptime', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index e7c878b6f40173..a1637f62b01c17 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -94,9 +94,7 @@ def _create_mock_monitor_observer(monitor, callback, name): "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 @@ def _create_mock_monitor_observer(monitor, callback, name): "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 @@ def _create_mock_monitor_observer(monitor, callback, name): "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/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 5c8d8d4253ce41..0ac8140c52dc5c 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -2,8 +2,8 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.components.select import ( @@ -95,7 +95,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -116,7 +116,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -144,7 +144,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, @@ -221,7 +221,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -242,7 +242,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -270,7 +270,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 2c64338c4f3d28..d77c2db356a302 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1,6 +1,5 @@ """The tests for the utility_meter sensor platform.""" from datetime import timedelta -from unittest.mock import patch from freezegun import freeze_time import pytest @@ -132,7 +131,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -166,7 +165,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, @@ -729,7 +728,7 @@ async def test_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -803,7 +802,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -813,7 +812,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, None, @@ -1148,7 +1147,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -1186,7 +1185,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get("status") == COLLECTING now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 7c5c0de1674762..0b44476989b342 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -5,7 +5,11 @@ import pytest -from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN, VacuumEntity +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + VacuumEntity, + VacuumEntityFeature, +) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -89,7 +93,7 @@ async def async_setup_entry_platform( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test vacuum platform via config entry.""" async_add_entities([entity1]) mock_platform( @@ -121,3 +125,23 @@ async def async_setup_entry_platform( issue.translation_placeholders == {"platform": "test"} | translation_placeholders_extra ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockVacuumEntity(VacuumEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockVacuumEntity() + assert entity.supported_features_compat is VacuumEntityFeature(1) + assert "MockVacuumEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "VacuumEntityFeature.TURN_ON" in caplog.text + caplog.clear() + assert entity.supported_features_compat is VacuumEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/vacuum/test_significant_change.py b/tests/components/vacuum/test_significant_change.py new file mode 100644 index 00000000000000..5f46080fb8deb5 --- /dev/null +++ b/tests/components/vacuum/test_significant_change.py @@ -0,0 +1,51 @@ +"""Test the Vacuum significant change platform.""" +import pytest + +from homeassistant.components.vacuum import ( + ATTR_BATTERY_ICON, + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, +) +from homeassistant.components.vacuum.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Vacuum significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_FAN_SPEED: "old_value"}, {ATTR_FAN_SPEED: "old_value"}, False), + ({ATTR_FAN_SPEED: "old_value"}, {ATTR_FAN_SPEED: "new_value"}, True), + # multiple attributes + ( + {ATTR_FAN_SPEED: "old_value", ATTR_BATTERY_LEVEL: 10.0}, + {ATTR_FAN_SPEED: "new_value", ATTR_BATTERY_LEVEL: 10.0}, + True, + ), + # float attributes + ({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: 11.0}, True), + ({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: 10.9}, False), + ({ATTR_BATTERY_LEVEL: "invalid"}, {ATTR_BATTERY_LEVEL: 10.0}, True), + ({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: "invalid"}, False), + # insignificant attributes + ({ATTR_BATTERY_ICON: "old_value"}, {ATTR_BATTERY_ICON: "new_value"}, False), + ({ATTR_BATTERY_ICON: "old_value"}, {ATTR_BATTERY_ICON: "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Vacuum significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/vallox/test_fan.py b/tests/components/vallox/test_fan.py index eb60a3d025db1f..12b24f46abaac9 100644 --- a/tests/components/vallox/test_fan.py +++ b/tests/components/vallox/test_fan.py @@ -10,6 +10,7 @@ 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/valve/__init__.py b/tests/components/valve/__init__.py new file mode 100644 index 00000000000000..c39ec8220af675 --- /dev/null +++ b/tests/components/valve/__init__.py @@ -0,0 +1 @@ +"""Tests for the valve component.""" diff --git a/tests/components/valve/test_init.py b/tests/components/valve/test_init.py new file mode 100644 index 00000000000000..08b0771da8edfe --- /dev/null +++ b/tests/components/valve/test_init.py @@ -0,0 +1,355 @@ +"""The tests for Valve.""" +from collections.abc import Generator + +import pytest + +from homeassistant.components.valve import ( + DOMAIN, + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_SET_VALVE_POSITION, + SERVICE_TOGGLE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant, callback +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.""" + + +class MockValveEntity(ValveEntity): + """Mock valve device to use in tests.""" + + _attr_should_poll = False + _target_valve_position: int + + def __init__( + self, + unique_id: str = "mock_valve", + name: str = "Valve", + features: ValveEntityFeature = ValveEntityFeature(0), + current_position: int = None, + device_class: ValveDeviceClass = None, + reports_position: bool = True, + ) -> None: + """Initialize the valve.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + self._attr_current_valve_position = current_position + if reports_position is not None: + self._attr_reports_position = reports_position + if device_class is not None: + self._attr_device_class = device_class + + def set_valve_position(self, position: int) -> None: + """Set the valve to opening or closing towards a target percentage.""" + if position > self._attr_current_valve_position: + self._attr_is_closing = False + self._attr_is_opening = True + else: + self._attr_is_closing = True + self._attr_is_opening = False + self._target_valve_position = position + self.schedule_update_ha_state() + + def stop_valve(self) -> None: + """Stop the valve.""" + self._attr_is_closing = False + self._attr_is_opening = False + self._target_valve_position = None + self._attr_is_closed = self._attr_current_valve_position == 0 + self.schedule_update_ha_state() + + @callback + def finish_movement(self): + """Set the value to the saved target and removes intermediate states.""" + self._attr_current_valve_position = self._target_valve_position + self._attr_is_closing = False + self._attr_is_opening = False + self.async_write_ha_state() + + +class MockBinaryValveEntity(ValveEntity): + """Mock valve device to use in tests.""" + + def __init__( + self, + unique_id: str = "mock_valve_2", + name: str = "Valve", + features: ValveEntityFeature = ValveEntityFeature(0), + is_closed: bool = None, + ) -> None: + """Initialize the valve.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + self._attr_is_closed = is_closed + self._attr_reports_position = False + + def open_valve(self) -> None: + """Open the valve.""" + self._attr_is_closed = False + + def close_valve(self) -> None: + """Mock implementantion for sync close function.""" + self._attr_is_closed = True + + +@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 +def mock_config_entry(hass) -> tuple[MockConfigEntry, list[ValveEntity]]: + """Mock a config entry which sets up a couple of valve entities.""" + entities = [ + MockBinaryValveEntity( + is_closed=False, + features=ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, + ), + MockValveEntity( + current_position=50, + features=ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION, + ), + ] + + 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, Platform.VALVE + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload up test config entry.""" + await hass.config_entries.async_unload_platforms(config_entry, [Platform.VALVE]) + 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 async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test 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) + + return (config_entry, entities) + + +async def test_valve_setup( + hass: HomeAssistant, mock_config_entry: tuple[MockConfigEntry, list[ValveEntity]] +) -> None: + """Test setup and tear down of valve platform and entity.""" + config_entry = mock_config_entry[0] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = mock_config_entry[1][0].entity_id + + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get(entity_id) + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + entity_state = hass.states.get(entity_id) + + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + +async def test_services( + hass: HomeAssistant, mock_config_entry: tuple[MockConfigEntry, list[ValveEntity]] +) -> None: + """Test the provided services.""" + config_entry = mock_config_entry[0] + ent1, ent2 = mock_config_entry[1] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test init all valves should be open + assert is_open(hass, ent1) + assert is_open(hass, ent2) + + # call basic toggle services + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + + # entities without stop should be closed and with stop should be closing + assert is_closed(hass, ent1) + assert is_closing(hass, ent2) + ent2.finish_movement() + assert is_closed(hass, ent2) + + # call basic toggle services and set different valve position states + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + await hass.async_block_till_done() + + # entities should be in correct state depending on the SUPPORT_STOP feature and valve position + assert is_open(hass, ent1) + assert is_opening(hass, ent2) + + # call basic toggle services + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + + # entities should be in correct state depending on the SUPPORT_STOP feature and valve position + assert is_closed(hass, ent1) + assert not is_opening(hass, ent2) + assert not is_closing(hass, ent2) + assert is_closed(hass, ent2) + + await call_service(hass, SERVICE_SET_VALVE_POSITION, ent2, 50) + assert is_opening(hass, ent2) + + +async def test_valve_device_class(hass: HomeAssistant) -> None: + """Test valve entity with defaults.""" + default_valve = MockValveEntity() + default_valve.hass = hass + + assert default_valve.device_class is None + + entity_description = ValveEntityDescription( + key="test", + device_class=ValveDeviceClass.GAS, + ) + default_valve.entity_description = entity_description + assert default_valve.device_class is ValveDeviceClass.GAS + + water_valve = MockValveEntity(device_class=ValveDeviceClass.WATER) + water_valve.hass = hass + + assert water_valve.device_class is ValveDeviceClass.WATER + + +async def test_valve_report_position(hass: HomeAssistant) -> None: + """Test valve entity with defaults.""" + default_valve = MockValveEntity(reports_position=None) + default_valve.hass = hass + + with pytest.raises(ValueError): + default_valve.reports_position + + second_valve = MockValveEntity(reports_position=True) + second_valve.hass = hass + + assert second_valve.reports_position is True + + entity_description = ValveEntityDescription(key="test", reports_position=True) + third_valve = MockValveEntity(reports_position=None) + third_valve.entity_description = entity_description + assert third_valve.reports_position is True + + +async def test_none_state(hass: HomeAssistant) -> None: + """Test different criteria for closeness.""" + binary_valve_with_none_is_closed_attr = MockBinaryValveEntity(is_closed=None) + binary_valve_with_none_is_closed_attr.hass = hass + + assert binary_valve_with_none_is_closed_attr.state is None + + pos_valve_with_none_is_closed_attr = MockValveEntity() + pos_valve_with_none_is_closed_attr.hass = hass + + assert pos_valve_with_none_is_closed_attr.state is None + + +async def test_supported_features(hass: HomeAssistant) -> None: + """Test valve entity with defaults.""" + valve = MockValveEntity(features=None) + valve.hass = hass + + assert valve.supported_features is None + + +def call_service(hass, service, ent, position=None): + """Call any service on entity.""" + params = {ATTR_ENTITY_ID: ent.entity_id} + if position is not None: + params["position"] = position + return hass.services.async_call(DOMAIN, service, params, blocking=True) + + +def set_valve_position(ent, position) -> None: + """Set a position value to a valve.""" + ent._values["current_valve_position"] = position + + +def is_open(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_OPEN) + + +def is_opening(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_OPENING) + + +def is_closed(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_CLOSED) + + +def is_closing(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_CLOSING) diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index dc1b217948fd79..dfc29d46cc243f 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4705,6 +4705,7 @@ 'disabled_by': None, 'domain': 'vicare', 'entry_id': '1234', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 7f70c13f0b01e3..283f06b754d22d 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -10,7 +10,7 @@ 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 0aa59c9271f4cc..b893d2df550290 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/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 660de3ff6b6b4e..142c5f74b84065 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import call, patch +from freezegun import freeze_time import pytest from pyvizio.api.apps import AppConfig from pyvizio.const import ( @@ -472,7 +473,7 @@ async def _test_update_availability_switch( future_interval = timedelta(minutes=1) # Setup device as if time is right now - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): await _test_setup_speaker(hass, initial_power_state) # Clear captured logs so that only availability state changes are captured for @@ -485,9 +486,7 @@ async def _test_update_availability_switch( with patch( "homeassistant.components.vizio.media_player.VizioAsync.get_power_state", return_value=final_power_state, - ), patch("homeassistant.util.dt.utcnow", return_value=future), patch( - "homeassistant.util.utcnow", return_value=future - ): + ), freeze_time(future): async_fire_time_changed(hass, future) await hass.async_block_till_done() if final_power_state is None: diff --git a/tests/components/vlc_telnet/test_config_flow.py b/tests/components/vlc_telnet/test_config_flow.py index 91ea5b3e439097..a94f290f7e6888 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 982a14a80f4218..00b1ae6e72ac56 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/voip/test_voip.py b/tests/components/voip/test_voip.py index f82a00087c6dcc..dbb848f3b9dc55 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 @@ _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 async_get_media_source_audio( 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 async_get_media_source_audio( 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,197 @@ async def send_tts(*args, **kwargs): 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)) + + # "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_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)) @@ -320,3 +528,75 @@ async def send_tts(*args, **kwargs): # 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 738b9bf7bd6308..837df4dfd47eff 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -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( diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index 7a95e000d82a58..ecc7e07158d20e 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/water_heater/test_init.py b/tests/components/water_heater/test_init.py index bc996ab6fa4878..861be19234088f 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -6,14 +6,18 @@ import pytest import voluptuous as vol +from homeassistant.components import water_heater from homeassistant.components.water_heater import ( + ATTR_OPERATION_LIST, + ATTR_OPERATION_MODE, SET_TEMPERATURE_SCHEMA, WaterHeaterEntity, WaterHeaterEntityFeature, ) +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from tests.common import async_mock_service +from tests.common import async_mock_service, import_and_test_deprecated_constant_enum async def test_set_temp_schema_no_req( @@ -96,3 +100,46 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: await water_heater.async_turn_off() assert water_heater.async_turn_off.call_count == 1 + + +@pytest.mark.parametrize( + ("enum"), + [ + WaterHeaterEntityFeature.TARGET_TEMPERATURE, + WaterHeaterEntityFeature.OPERATION_MODE, + WaterHeaterEntityFeature.AWAY_MODE, + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: WaterHeaterEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, water_heater, enum, "SUPPORT_", "2025.1" + ) + + +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test deprecated supported features ints.""" + + class MockWaterHeaterEntity(WaterHeaterEntity): + _attr_operation_list = ["mode1", "mode2"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_current_operation = "mode1" + _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE.value + + entity = MockWaterHeaterEntity() + entity.hass = hass + assert entity.supported_features_compat is WaterHeaterEntityFeature(2) + assert "MockWaterHeaterEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "WaterHeaterEntityFeature.OPERATION_MODE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is WaterHeaterEntityFeature(2) + assert "is using deprecated supported features values" not in caplog.text + assert entity.state_attributes[ATTR_OPERATION_MODE] == "mode1" + assert entity.capability_attributes[ATTR_OPERATION_LIST] == ["mode1", "mode2"] diff --git a/tests/components/water_heater/test_significant_change.py b/tests/components/water_heater/test_significant_change.py new file mode 100644 index 00000000000000..40803eea09a235 --- /dev/null +++ b/tests/components/water_heater/test_significant_change.py @@ -0,0 +1,96 @@ +"""Test the Water Heater significant change platform.""" +import pytest + +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_CURRENT_TEMPERATURE, + ATTR_OPERATION_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) +from homeassistant.components.water_heater.significant_change import ( + async_check_significant_change, +) +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + METRIC_SYSTEM as METRIC, + US_CUSTOMARY_SYSTEM as IMPERIAL, + UnitSystem, +) + + +async def test_significant_state_change(hass: HomeAssistant) -> None: + """Detect Water Heater significant state changes.""" + attrs = {} + assert not async_check_significant_change(hass, "on", attrs, "on", attrs) + assert async_check_significant_change(hass, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("unit_system", "old_attrs", "new_attrs", "expected_result"), + [ + (METRIC, {ATTR_AWAY_MODE: "old_value"}, {ATTR_AWAY_MODE: "old_value"}, False), + (METRIC, {ATTR_AWAY_MODE: "old_value"}, {ATTR_AWAY_MODE: "new_value"}, True), + ( + METRIC, + {ATTR_OPERATION_MODE: "old_value"}, + {ATTR_OPERATION_MODE: "new_value"}, + True, + ), + # multiple attributes + ( + METRIC, + {ATTR_AWAY_MODE: "old_value", ATTR_OPERATION_MODE: "old_value"}, + {ATTR_AWAY_MODE: "new_value", ATTR_OPERATION_MODE: "old_value"}, + True, + ), + # float attributes + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 50.0}, + {ATTR_CURRENT_TEMPERATURE: 50.5}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 50.0}, + {ATTR_CURRENT_TEMPERATURE: 50.4}, + False, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: "invalid"}, + {ATTR_CURRENT_TEMPERATURE: 10.0}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 10.0}, + {ATTR_CURRENT_TEMPERATURE: "invalid"}, + False, + ), + (IMPERIAL, {ATTR_TEMPERATURE: 160.0}, {ATTR_TEMPERATURE: 161}, True), + (IMPERIAL, {ATTR_TEMPERATURE: 160.0}, {ATTR_TEMPERATURE: 160.9}, False), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 80.0}, {ATTR_TARGET_TEMP_HIGH: 80.5}, True), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 80.0}, {ATTR_TARGET_TEMP_HIGH: 80.4}, False), + (METRIC, {ATTR_TARGET_TEMP_LOW: 30.0}, {ATTR_TARGET_TEMP_LOW: 30.5}, True), + (METRIC, {ATTR_TARGET_TEMP_LOW: 30.0}, {ATTR_TARGET_TEMP_LOW: 30.4}, False), + # insignificant attributes + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + hass: HomeAssistant, + unit_system: UnitSystem, + old_attrs: dict, + new_attrs: dict, + expected_result: bool, +) -> None: + """Detect Water Heater significant attribute changes.""" + hass.config.units = unit_system + assert ( + async_check_significant_change(hass, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/watttime/conftest.py b/tests/components/watttime/conftest.py index f3c1986fcb021a..f636ffefcfb386 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/watttime/snapshots/test_diagnostics.ambr b/tests/components/watttime/snapshots/test_diagnostics.ambr index e1cf4a8a42f505..2ed35c19ad17de 100644 --- a/tests/components/watttime/snapshots/test_diagnostics.ambr +++ b/tests/components/watttime/snapshots/test_diagnostics.ambr @@ -19,6 +19,7 @@ }), 'disabled_by': None, 'domain': 'watttime', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/watttime/test_config_flow.py b/tests/components/watttime/test_config_flow.py index dbe1e5444d7e2b..ce9284924f543d 100644 --- a/tests/components/watttime/test_config_flow.py +++ b/tests/components/watttime/test_config_flow.py @@ -12,13 +12,13 @@ from homeassistant.components.watttime.const import ( CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, - CONF_SHOW_ON_MAP, DOMAIN, ) from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + CONF_SHOW_ON_MAP, CONF_USERNAME, ) from homeassistant.core import HomeAssistant diff --git a/tests/components/weather/snapshots/test_init.ambr b/tests/components/weather/snapshots/test_init.ambr index 03a2d46c80ff29..1aa78f6bf3502e 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 f62bed295daf69..b982ab610ec74f 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -32,8 +32,9 @@ 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, @@ -959,6 +960,13 @@ def forecast(self) -> list[Forecast] | None: assert msg["type"] == "result" +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize( ("forecast_type", "supported_features"), [ @@ -976,6 +984,7 @@ async def test_get_forecast( forecast_type: str, supported_features: int, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test get forecast service.""" @@ -1006,7 +1015,7 @@ async def async_forecast_hourly(self) -> list[Forecast] | None: response = await hass.services.async_call( DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity0.entity_id, "type": forecast_type, @@ -1017,9 +1026,30 @@ async def async_forecast_hourly(self) -> list[Forecast] | None: 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, config_flow_fixture: None, + service: str, + expected: dict[str, list | dict[str, list]], ) -> None: """Test get forecast service.""" @@ -1040,7 +1070,7 @@ async def async_forecast_daily(self) -> list[Forecast] | None: response = await hass.services.async_call( DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity0.entity_id, "type": "daily", @@ -1048,11 +1078,16 @@ async def async_forecast_daily(self) -> list[Forecast] | None: 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"), [ @@ -1066,6 +1101,7 @@ async def test_get_forecast_unsupported( config_flow_fixture: None, forecast_types: list[str], supported_features: int, + service: str, ) -> None: """Test get forecast service.""" @@ -1095,7 +1131,7 @@ async def async_forecast_hourly(self) -> list[Forecast] | None: with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": weather_entity.entity_id, "type": forecast_type, @@ -1250,8 +1286,57 @@ async def async_forecast_daily(self) -> list[Forecast] | None: assert weather_entity.state == ATTR_CONDITION_SUNNY - assert "Setting up weather.test" in caplog.text + assert "Setting up test.weather" 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 00000000000000..1a171da7fae01d --- /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_significant_change.py b/tests/components/weather/test_significant_change.py new file mode 100644 index 00000000000000..93e5830a0acb82 --- /dev/null +++ b/tests/components/weather/test_significant_change.py @@ -0,0 +1,347 @@ +"""Test the Weather significant change platform.""" + +import pytest + +from homeassistant.components.weather.const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) +from homeassistant.components.weather.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature + + +async def test_significant_state_change() -> None: + """Detect Weather significant state changes.""" + assert not async_check_significant_change( + None, "clear-night", {}, "clear-night", {} + ) + assert async_check_significant_change(None, "clear-night", {}, "cloudy", {}) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # insignificant attributes + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b"}, + False, + ), + ({ATTR_WEATHER_PRESSURE_UNIT: "a"}, {ATTR_WEATHER_PRESSURE_UNIT: "b"}, False), + ( + {ATTR_WEATHER_TEMPERATURE_UNIT: "a"}, + {ATTR_WEATHER_TEMPERATURE_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_VISIBILITY_UNIT: "a"}, + {ATTR_WEATHER_VISIBILITY_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_WIND_SPEED_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + False, + ), + # significant attributes, close to but not significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 68.9, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + False, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.4}, + False, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 80.9}, + False, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "W"}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.09}, + False, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 20.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1000.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 750.06}, + { + ATTR_WEATHER_PRESSURE: 750.74, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.54, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + False, + ), + # significant attributes with significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 69, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + True, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.5}, + True, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 81}, + True, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "NW"}, # NW = 315° + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.1}, + True, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 21}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1001}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 750}, + { + ATTR_WEATHER_PRESSURE: 749, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.55, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + True, + ), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # invalid new values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + False, + ), + # invalid old values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + True, + ), + ], +) +async def test_invalid_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather invalid attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/weatherkit/snapshots/test_weather.ambr b/tests/components/weatherkit/snapshots/test_weather.ambr index 63321b5a81321f..1fbe5389e980cd 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_coordinator.py b/tests/components/weatherkit/test_coordinator.py index f619ace237ae3b..7113e1d4d51c08 100644 --- a/tests/components/weatherkit/test_coordinator.py +++ b/tests/components/weatherkit/test_coordinator.py @@ -23,7 +23,7 @@ async def test_failed_updates(hass: HomeAssistant) -> None: ): async_fire_time_changed( hass, - utcnow() + timedelta(minutes=15), + utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/weatherkit/test_weather.py b/tests/components/weatherkit/test_weather.py index fabd3aab572d37..3b3a9a50d7fbd8 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 @@ 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/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index f0f411fb27898d..b7d1646c6b683e 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -44,6 +44,7 @@ async def test_diagnostics( "entry": { "entry_id": entry.entry_id, "version": 1, + "minor_version": 1, "domain": "webostv", "title": "fake_webos", "data": { diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index 9cbf8768dd57fa..74573e2185bf19 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -60,7 +60,7 @@ async def test_webostv_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index a9551310c2a167..127b45484be32a 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2317,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 da435d64d58824..80936d30752c6c 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -39,9 +39,15 @@ ), ( 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 (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 (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)", ), ( ValueError("Really bad"), diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index 35ed55183d44d9..24387a89a29205 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -237,6 +237,50 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: } } + hass.states.async_set( + "light.window", + "green", + {"list_attr": ["a", "b", "c", "d"], "list_attr_2": ["a", "b"]}, + context=new_context, + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "a": {"list_attr": ["a", "b", "c", "d"], "list_attr_2": ["a", "b"]}, + "lu": new_state.last_updated.timestamp(), + } + } + } + } + + hass.states.async_set( + "light.window", + "green", + {"list_attr": ["a", "b", "c", "e"]}, + context=new_context, + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + assert message == { + "c": { + "light.window": { + "+": { + "a": {"list_attr": ["a", "b", "c", "e"]}, + "lu": new_state.last_updated.timestamp(), + }, + "-": {"a": ["list_attr_2"]}, + } + } + } + async def test_message_to_json(caplog: pytest.LogCaptureFixture) -> None: """Test we can serialize websocket messages.""" diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index fe2f9f1750446b..8607a49b42cec9 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -43,6 +43,7 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import init_integration @@ -337,7 +338,7 @@ class ClimateInstancesData: mock_instance.set_fanspeed.reset_mock() # FAN_MIDDLE is not supported - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 6eec94d42a5235..08f3861dcd249a 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -12,6 +12,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -21,6 +22,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -48,6 +50,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -57,6 +60,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -84,6 +88,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -93,6 +98,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -120,6 +126,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -129,6 +136,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -156,6 +164,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -165,6 +174,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 59d9b470247c7d..4ca4093e3b8c59 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 bb5c93e1f0979b..928eccdde0f690 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 3f20791ac4d828..390fbc3bbc3ccf 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 f9e44359b0070b..fb436a57e5c09c 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -65,6 +65,17 @@ async def init_integration( "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 @@ async def init_integration( "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_INCORRECT_PROVINCE = { "name": DEFAULT_NAME, @@ -84,6 +96,7 @@ async def init_integration( "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_NO_PROVINCE = { "name": DEFAULT_NAME, @@ -93,6 +106,7 @@ async def init_integration( "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_WITH_STATE = { "name": DEFAULT_NAME, @@ -103,6 +117,7 @@ async def init_integration( "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "en_US", } TEST_CONFIG_NO_STATE = { "name": DEFAULT_NAME, @@ -112,6 +127,7 @@ async def init_integration( "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "en_US", } TEST_CONFIG_INCLUDE_HOLIDAY = { "name": DEFAULT_NAME, @@ -122,6 +138,7 @@ async def init_integration( "workdays": ["holiday"], "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_EXAMPLE_1 = { "name": DEFAULT_NAME, @@ -131,6 +148,7 @@ async def init_integration( "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "en_US", } TEST_CONFIG_EXAMPLE_2 = { "name": DEFAULT_NAME, @@ -141,6 +159,7 @@ async def init_integration( "workdays": ["mon", "wed", "fri"], "add_holidays": ["2020-02-24"], "remove_holidays": [], + "language": "de", } TEST_CONFIG_REMOVE_HOLIDAY = { "name": DEFAULT_NAME, @@ -150,6 +169,7 @@ async def init_integration( "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 @@ async def init_integration( "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 @@ async def init_integration( "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_DAY_AFTER_TOMORROW = { "name": DEFAULT_NAME, @@ -177,6 +199,7 @@ async def init_integration( "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_YESTERDAY = { "name": DEFAULT_NAME, @@ -186,6 +209,7 @@ async def init_integration( "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_INCORRECT_ADD_REMOVE = { "name": DEFAULT_NAME, @@ -196,6 +220,7 @@ async def init_integration( "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 @@ async def init_integration( "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 @@ async def init_integration( "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 @@ async def init_integration( "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 @@ async def init_integration( "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 @@ async def init_integration( "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 e955bd0de0d501..a359d83d87db2a 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -26,6 +26,7 @@ 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, @@ -34,6 +35,8 @@ TEST_CONFIG_WITH_PROVINCE, TEST_CONFIG_WITH_STATE, TEST_CONFIG_YESTERDAY, + TEST_LANGUAGE_CHANGE, + TEST_LANGUAGE_NO_CHANGE, init_integration, ) @@ -51,6 +54,7 @@ (TEST_CONFIG_TOMORROW, "off"), (TEST_CONFIG_DAY_AFTER_TOMORROW, "off"), (TEST_CONFIG_YESTERDAY, "on"), + (TEST_CONFIG_NO_LANGUAGE_CONFIGURED, "off"), ], ) async def test_setup( @@ -311,3 +315,33 @@ async def test_check_date_service( return_response=True, ) assert response == {"binary_sensor.workday_sensor": {"workday": True}} + + response = await hass.services.async_call( + DOMAIN, + SERVICE_CHECK_DATE, + { + "entity_id": "binary_sensor.workday_sensor", + "check_date": date(2022, 12, 17), # Saturday (no workday) + }, + blocking=True, + return_response=True, + ) + assert response == {"binary_sensor.workday_sensor": {"workday": False}} + + +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 89a001e0b55acf..fb0d78365e8c0a 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -1,12 +1,14 @@ """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 from homeassistant.components.workday.const import ( CONF_ADD_HOLIDAYS, - CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, CONF_REMOVE_HOLIDAYS, @@ -16,9 +18,10 @@ DEFAULT_WORKDAYS, DOMAIN, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_COUNTRY, 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 +52,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 +67,7 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "de", } @@ -143,6 +148,7 @@ async def test_form_no_subdivision(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "sv", } @@ -159,6 +165,7 @@ async def test_options_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "de", }, ) @@ -173,6 +180,7 @@ async def test_options_form(hass: HomeAssistant) -> None: "add_holidays": [], "remove_holidays": [], "province": "BW", + "language": "de", }, ) @@ -186,6 +194,7 @@ async def test_options_form(hass: HomeAssistant) -> None: "add_holidays": [], "remove_holidays": [], "province": "BW", + "language": "de", } @@ -213,6 +222,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 +236,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 +251,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 +266,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 +283,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 +298,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "add_holidays": ["2022-xx-12"], "remove_holidays": [], "province": "BW", + "language": "de", }, ) @@ -298,6 +313,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 +328,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 +342,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 +419,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 +433,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 +448,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 +463,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 +480,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 +495,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 +510,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 +525,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 +539,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/workday/test_repairs.py b/tests/components/workday/test_repairs.py index d1920b7dc26b5e..fc7bfeb1b0e4de 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -7,7 +7,7 @@ RepairsFlowIndexView, RepairsFlowResourceView, ) -from homeassistant.components.workday.const import DOMAIN +from homeassistant.components.workday.const import CONF_REMOVE_HOLIDAYS, DOMAIN from homeassistant.const import CONF_COUNTRY from homeassistant.core import HomeAssistant from homeassistant.helpers.issue_registry import async_create_issue @@ -16,6 +16,7 @@ from . import ( TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_REMOVE_NAMED, init_integration, ) @@ -324,6 +325,83 @@ async def test_bad_province_none( assert not issue +async def test_bad_named_holiday( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_REMOVE_NAMED) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_named_holiday-1-not_a_holiday": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post( + url, + json={"handler": DOMAIN, "issue_id": "bad_named_holiday-1-not_a_holiday"}, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "US", + CONF_REMOVE_HOLIDAYS: "Not a Holiday", + "title": entry.title, + } + assert data["step_id"] == "named_holiday" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post( + url, json={"remove_holidays": ["Christmas", "Not exist 2"]} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["errors"] == { + CONF_REMOVE_HOLIDAYS: "remove_holiday_error", + } + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post( + url, json={"remove_holidays": ["Christmas", "Thanksgiving"]} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_named_holiday-1-not_a_holiday": + issue = i + assert not issue + + async def test_other_fixable_issues( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index e04ff4eda03afe..268ebef1d06635 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,11 +1,14 @@ """Tests for the Wyoming integration.""" import asyncio +from unittest.mock import patch +from wyoming.event import Event from wyoming.info import ( AsrModel, AsrProgram, Attribution, Info, + Satellite, TtsProgram, TtsVoice, TtsVoiceSpeaker, @@ -13,6 +16,10 @@ WakeProgram, ) +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.core import HomeAssistant + TEST_ATTR = Attribution(name="Test", url="http://www.test.com") STT_INFO = Info( asr=[ @@ -72,24 +79,36 @@ ) ] ) +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,8 +124,24 @@ async def __aenter__(self): 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 return self + + +async def reload_satellite( + hass: HomeAssistant, config_entry_id: str +) -> SatelliteDevice: + """Reload config entry with satellite info and returns new device.""" + 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_reload(config_entry_id) + + return hass.data[DOMAIN][config_entry_id].satellite.device diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 2c8081908f772d..f22ec7e9e16cba 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -5,14 +5,29 @@ 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) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + +@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 +125,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 d4220a3972424d..a0e0c7c5011ab8 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -55,6 +55,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'wyoming', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -65,6 +66,7 @@ 'disabled_by': None, 'domain': 'wyoming', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -97,6 +99,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'wyoming', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -107,6 +110,7 @@ 'disabled_by': None, 'domain': 'wyoming', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -121,3 +125,47 @@ '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', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'disabled_by': None, + 'domain': 'wyoming', + 'entry_id': , + 'minor_version': 1, + '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_stt.ambr b/tests/components/wyoming/snapshots/test_stt.ambr index 784f89b2ab8117..b45b7508b2819e 100644 --- a/tests/components/wyoming/snapshots/test_stt.ambr +++ b/tests/components/wyoming/snapshots/test_stt.ambr @@ -6,7 +6,7 @@ 'language': 'en', }), 'payload': None, - 'type': 'transcibe', + 'type': 'transcribe', }), dict({ 'data': dict({ diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py new file mode 100644 index 00000000000000..fba181a63ca424 --- /dev/null +++ b/tests/components/wyoming/test_binary_sensor.py @@ -0,0 +1,37 @@ +"""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 + +from . import reload_satellite + + +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 + + # test restore does *not* happen + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_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 diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 896d3748ebdc6e..f711b56b3bc5f6 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 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 @@ 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 0cb878c39c1edb..b7de9dbfdc1a17 100644 --- a/tests/components/wyoming/test_data.py +++ b/tests/components/wyoming/test_data.py @@ -3,13 +3,15 @@ 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 00000000000000..0273a7da275261 --- /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 +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 + + muted_id = satellite_device.get_muted_entity_id(hass) + assert muted_id + muted_state = hass.states.get(muted_id) + assert muted_state is not None + assert muted_state.state == STATE_OFF + + 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 + + muted_id = satellite_device.get_muted_entity_id(hass) + assert muted_id + assert hass.states.get(muted_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(muted_id) is None + assert hass.states.get(pipeline_entity_id) is None diff --git a/tests/components/wyoming/test_number.py b/tests/components/wyoming/test_number.py new file mode 100644 index 00000000000000..084021d61a7807 --- /dev/null +++ b/tests/components/wyoming/test_number.py @@ -0,0 +1,102 @@ +"""Test Wyoming number.""" +from unittest.mock import patch + +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import reload_satellite + + +async def test_auto_gain_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test automatic gain control number.""" + agc_entity_id = satellite_device.get_auto_gain_entity_id(hass) + assert agc_entity_id + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 0 + assert satellite_device.auto_gain == 0 + + # Change setting + with patch.object(satellite_device, "set_auto_gain") as mock_agc_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 31}, + blocking=True, + ) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + # set function should have been called + mock_agc_changed.assert_called_once_with(31) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 15}, + blocking=True, + ) + + assert satellite_device.auto_gain == 15 + + +async def test_volume_multiplier_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test volume multiplier number.""" + vm_entity_id = satellite_device.get_volume_multiplier_entity_id(hass) + assert vm_entity_id + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 1.0 + assert satellite_device.volume_multiplier == 1.0 + + # Change setting + with patch.object(satellite_device, "set_volume_multiplier") as mock_vm_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 2.0}, + blocking=True, + ) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + # set function should have been called + mock_vm_changed.assert_called_once_with(2.0) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 0.5}, + blocking=True, + ) + + assert float(satellite_device.volume_multiplier) == 0.5 diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py new file mode 100644 index 00000000000000..07a6aa8925e19f --- /dev/null +++ b/tests/components/wyoming/test_satellite.py @@ -0,0 +1,521 @@ +"""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.error import Error +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.error_event = asyncio.Event() + self.error: Error | 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() + elif Error.is_type(event.type): + self.error = Error.from_event(event) + self.error_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_once() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + assert mock_run_pipeline.call_args.kwargs.get("device_id") == device.device_id + + # 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 not device.is_muted + + # 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_muted(hass: HomeAssistant) -> None: + """Test callback for a satellite that has been muted.""" + on_muted_event = asyncio.Event() + + original_make_satellite = wyoming._make_satellite + + def make_muted_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService + ): + satellite = original_make_satellite(hass, config_entry, service) + satellite.device.set_is_muted(True) + + return satellite + + async def on_muted(self): + self.device.set_is_muted(False) + on_muted_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming._make_satellite", make_muted_satellite + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_muted", + on_muted, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_muted_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.""" + num_reconnects = 0 + reconnect_event = asyncio.Event() + stopped_event = asyncio.Event() + + async def on_reconnect(self): + nonlocal num_reconnects + num_reconnects += 1 + if num_reconnects >= 2: + reconnect_event.set() + self.stop() + + async def on_stopped(self): + stopped_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, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", + on_stopped, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await reconnect_event.wait() + await stopped_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 + + +async def test_satellite_error_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite error occurring during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + 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: + await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called_once() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.ERROR, + {"code": "test code", "message": "test message"}, + ) + ) + + async with asyncio.timeout(1): + await mock_client.error_event.wait() + + assert mock_client.error is not None + assert mock_client.error.text == "test message" + assert mock_client.error.code == "test code" diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py new file mode 100644 index 00000000000000..128aab57a1a0b2 --- /dev/null +++ b/tests/components/wyoming/test_select.py @@ -0,0 +1,141 @@ +"""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 + +from . import reload_satellite + + +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" + + # set function should have been called + mock_pipeline_changed.assert_called_once_with("Test 1") + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "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() + + +async def test_noise_suppression_level_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test noise suppression level select.""" + nsl_entity_id = satellite_device.get_noise_suppression_level_entity_id(hass) + assert nsl_entity_id + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "off" + assert satellite_device.noise_suppression_level == 0 + + # Change setting + with patch.object( + satellite_device, "set_noise_suppression_level" + ) as mock_nsl_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "max"}, + blocking=True, + ) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + # set function should have been called + mock_nsl_changed.assert_called_once_with(4) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "medium"}, + blocking=True, + ) + + assert satellite_device.noise_suppression_level == 2 diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py new file mode 100644 index 00000000000000..6246ba950030c8 --- /dev/null +++ b/tests/components/wyoming/test_switch.py @@ -0,0 +1,41 @@ +"""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 + +from . import reload_satellite + + +async def test_muted( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test satellite muted.""" + muted_id = satellite_device.get_muted_entity_id(hass) + assert muted_id + + state = hass.states.get(muted_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_muted + + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": muted_id}, + blocking=True, + ) + + state = hass.states.get(muted_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_muted + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(muted_id) + assert state is not None + assert state.state == STATE_ON diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 68b7b2b62bccc5..301074e8ffb47f 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -16,12 +16,6 @@ from . import MockAsyncTcpClient -@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 - - async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: """Test supported properties.""" state = hass.states.get("tts.test_tts") @@ -180,7 +174,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/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index 32d1fea7f62225..14ea3e44af8e17 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -23,6 +22,7 @@ from tests.components.bluetooth import ( inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -294,9 +294,8 @@ async def test_unavailable(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -347,9 +346,8 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -400,9 +398,8 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index b0ddd99a7c264f..ceca08a68eed43 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -1,7 +1,6 @@ """Test Xiaomi BLE sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -28,6 +27,7 @@ from tests.components.bluetooth import ( inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -692,9 +692,8 @@ async def test_unavailable(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -739,9 +738,8 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -788,9 +786,8 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/xiaomi_miio/test_button.py b/tests/components/xiaomi_miio/test_button.py index 995c5ae034cb0f..552b302aafe912 100644 --- a/tests/components/xiaomi_miio/test_button.py +++ b/tests/components/xiaomi_miio/test_button.py @@ -5,15 +5,15 @@ from homeassistant.components.button import DOMAIN, SERVICE_PRESS from homeassistant.components.xiaomi_miio.const import ( - CONF_DEVICE, CONF_FLOW_TYPE, - CONF_MAC, DOMAIN as XIAOMI_DOMAIN, MODELS_VACUUM, ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_DEVICE, CONF_HOST, + CONF_MAC, CONF_MODEL, CONF_TOKEN, Platform, diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index a436908b44ff96..b36924764fef44 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from . import TEST_MAC @@ -172,7 +172,7 @@ async def test_config_flow_gateway_success(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -205,7 +205,7 @@ async def test_config_flow_gateway_cloud_success(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -251,7 +251,7 @@ async def test_config_flow_gateway_cloud_multiple_success(hass: HomeAssistant) - CONF_HOST: TEST_HOST2, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC2, + CONF_MAC: TEST_MAC2, } @@ -460,7 +460,7 @@ async def test_zeroconf_gateway_success(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -685,14 +685,14 @@ async def test_config_flow_step_device_manual_model_succes(hass: HomeAssistant) assert result["type"] == "create_entry" assert result["title"] == overwrite_model assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: overwrite_model, - const.CONF_MAC: None, + CONF_MAC: None, } @@ -729,14 +729,14 @@ async def config_flow_device_success(hass, model_to_test): assert result["type"] == "create_entry" assert result["title"] == model_to_test assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: model_to_test, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -775,14 +775,14 @@ async def config_flow_generic_roborock(hass): assert result["type"] == "create_entry" assert result["title"] == dummy_model assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: dummy_model, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -829,14 +829,14 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): assert result["type"] == "create_entry" assert result["title"] == model_to_test assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: model_to_test, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -879,7 +879,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, }, title=TEST_NAME, ) @@ -919,7 +919,7 @@ async def test_options_flow_incomplete(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, }, title=TEST_NAME, ) @@ -957,7 +957,7 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, }, title=TEST_NAME, ) @@ -1005,5 +1005,5 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } diff --git a/tests/components/xiaomi_miio/test_select.py b/tests/components/xiaomi_miio/test_select.py index 48b8216bffc6c0..794fbb090e0897 100644 --- a/tests/components/xiaomi_miio/test_select.py +++ b/tests/components/xiaomi_miio/test_select.py @@ -17,20 +17,21 @@ ) from homeassistant.components.xiaomi_miio import UPDATE_INTERVAL from homeassistant.components.xiaomi_miio.const import ( - CONF_DEVICE, CONF_FLOW_TYPE, - CONF_MAC, DOMAIN as XIAOMI_DOMAIN, MODEL_AIRFRESH_T2017, ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_DEVICE, CONF_HOST, + CONF_MAC, CONF_MODEL, CONF_TOKEN, Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from . import TEST_MAC @@ -77,7 +78,7 @@ async def test_select_bad_attr(hass: HomeAssistant) -> None: assert state assert state.state == "forward" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( "select", SERVICE_SELECT_OPTION, diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 422a52b44ac082..9e823035dd9694 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -23,15 +23,14 @@ STATE_ERROR, ) from homeassistant.components.xiaomi_miio.const import ( - CONF_DEVICE, CONF_FLOW_TYPE, - CONF_MAC, DOMAIN as XIAOMI_DOMAIN, MODELS_VACUUM, ) from homeassistant.components.xiaomi_miio.vacuum import ( ATTR_ERROR, ATTR_TIMERS, + CONF_DEVICE, SERVICE_CLEAN_SEGMENT, SERVICE_CLEAN_ZONE, SERVICE_GOTO, @@ -44,6 +43,7 @@ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_HOST, + CONF_MAC, CONF_MODEL, CONF_TOKEN, STATE_UNAVAILABLE, diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index af393339eba547..6fc3259a4c0d0e 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -16,6 +16,7 @@ def _create_zone_mock(name, url): zone = MagicMock() zone.ctrl_url = url + zone.surround_programs = [] zone.zone = name return zone diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index ccccd98b3b6e90..4ce95e418d0726 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/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 0bd5b5f59d0bd2..e1d33ee5f75043 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/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 54406bb1b4d6a0..c94b2d66465088 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -34,7 +34,7 @@ HOMEKIT_STATUS_PAIRED = b"0" -def service_update_mock(ipv6, zeroconf, services, handlers, *, limit_service=None): +def service_update_mock(zeroconf, services, handlers, *, limit_service=None): """Call service update handler.""" for service in services: if limit_service is not None and service != limit_service: @@ -165,7 +165,7 @@ async def test_setup(hass: HomeAssistant, mock_async_zeroconf: None) -> None: ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, @@ -196,7 +196,7 @@ async def test_setup_with_overly_long_url_and_name( ) -> None: """Test we still setup with long urls and names.""" with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.get_url", return_value=( @@ -235,7 +235,7 @@ async def test_setup_with_defaults( ) -> None: """Test default interface config.""" with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, @@ -254,7 +254,7 @@ async def test_zeroconf_match_macaddress( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -278,7 +278,7 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), @@ -298,7 +298,7 @@ async def test_zeroconf_match_manufacturer( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -318,7 +318,7 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Samsung Electronics"), @@ -337,7 +337,7 @@ async def test_zeroconf_match_model( ) -> None: """Test matching a specific model in zeroconf.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -357,7 +357,7 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_model("appletv"), @@ -376,7 +376,7 @@ async def test_zeroconf_match_manufacturer_not_present( ) -> None: """Test matchers reject when a property is missing.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -396,7 +396,7 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("aabbccddeeff"), @@ -414,7 +414,7 @@ async def test_zeroconf_no_match( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -430,7 +430,7 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), @@ -448,7 +448,7 @@ async def test_zeroconf_no_match_manufacturer( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -468,7 +468,7 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Not Samsung Electronics"), @@ -497,7 +497,7 @@ async def test_homekit_match_partial_space( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -535,7 +535,7 @@ async def test_device_with_invalid_name( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -568,7 +568,7 @@ async def test_homekit_match_partial_dash( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -601,7 +601,7 @@ async def test_homekit_match_partial_fnmatch( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -634,7 +634,7 @@ async def test_homekit_match_full( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -670,7 +670,7 @@ async def test_homekit_already_paired( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -704,7 +704,7 @@ async def test_homekit_invalid_paring_status( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -732,7 +732,7 @@ async def test_homekit_not_paired( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_homekit_info_mock( @@ -770,7 +770,7 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -810,7 +810,7 @@ async def test_homekit_controller_still_discovered_unpaired_for_polling( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -940,7 +940,7 @@ async def test_get_instance(hass: HomeAssistant, mock_async_zeroconf: None) -> N async def test_removed_ignored(hass: HomeAssistant, mock_async_zeroconf: None) -> None: """Test we remove it when a zeroconf entry is removed.""" - def service_update_mock(ipv6, zeroconf, services, handlers): + def service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -962,7 +962,7 @@ def service_update_mock(ipv6, zeroconf, services, handlers): ) with patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, @@ -995,7 +995,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route( with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object( hass.config_entries.flow, "async_init" ), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, @@ -1079,7 +1079,7 @@ async def test_async_detect_interfaces_setting_empty_route_linux( with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, @@ -1109,7 +1109,7 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, @@ -1156,7 +1156,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, @@ -1181,7 +1181,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, @@ -1217,7 +1217,7 @@ async def test_setup_with_disallowed_characters_in_local_name( ) -> None: """Test we still setup with disallowed characters in the location name.""" with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch.object( hass.config, "location_name", @@ -1248,7 +1248,7 @@ async def test_start_with_frontend( async def test_zeroconf_removed(hass: HomeAssistant, mock_async_zeroconf: None) -> None: """Test we dismiss flows when a PTR record is removed.""" - def _device_removed_mock(ipv6, zeroconf, services, handlers): + def _device_removed_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -1275,7 +1275,7 @@ def _device_removed_mock(ipv6, zeroconf, services, handlers): ) as mock_async_progress_by_init_data_type, patch.object( hass.config_entries.flow, "async_abort" ) as mock_async_abort, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=_device_removed_mock + zeroconf, "AsyncServiceBrowser", side_effect=_device_removed_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 9d9d74e72dfeab..55405d0a51c812 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -26,7 +26,9 @@ 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 @@ -81,8 +83,8 @@ async def load_network_info(self, *, load_devices: bool = False): 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 b41499dada7789..5dd7a5653ecd79 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -9,8 +9,6 @@ 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_climate.py b/tests/components/zha/test_climate.py index 145aba799ca106..b693c0341990c3 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -52,7 +52,7 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -860,12 +860,13 @@ async def test_preset_setting_invalid( state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "invalid_preset"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "invalid_preset"}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE @@ -1251,13 +1252,14 @@ async def test_set_fan_mode_not_supported( entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan, hass) fan_cluster = device_climate_fan.device.endpoints[1].fan - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, - blocking=True, - ) - assert fan_cluster.write_attributes.await_count == 0 + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + assert fan_cluster.write_attributes.await_count == 0 async def test_set_fan_mode(hass: HomeAssistant, device_climate_fan) -> None: diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 24162296cd504a..39f201e668e7fe 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -3,6 +3,7 @@ from collections.abc import Callable import logging import math +from types import NoneType from unittest import mock from unittest.mock import AsyncMock, patch @@ -11,12 +12,17 @@ import zigpy.endpoint from zigpy.endpoint import Endpoint as ZigpyEndpoint import zigpy.profiles.zha +import zigpy.quirks as zigpy_quirks import zigpy.types as t from zigpy.zcl import foundation import zigpy.zcl.clusters +from zigpy.zcl.clusters import CLUSTERS_BY_ID import zigpy.zdo.types as zdo_t import homeassistant.components.zha.core.cluster_handlers as cluster_handlers +from homeassistant.components.zha.core.cluster_handlers.lighting import ( + ColorClusterHandler, +) import homeassistant.components.zha.core.const as zha_const from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.endpoint import Endpoint @@ -97,7 +103,9 @@ def poll_control_ch(endpoint, zigpy_device_mock): ) cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] - cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get(cluster_id) + cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id + ).get(None) return cluster_handler_class(cluster, endpoint) @@ -258,8 +266,8 @@ async def test_in_cluster_handler_config( cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, cluster_handlers.ClusterHandler - ) + cluster_id, {None, cluster_handlers.ClusterHandler} + ).get(None) cluster_handler = cluster_handler_class(cluster, endpoint) await cluster_handler.async_configure() @@ -322,8 +330,8 @@ async def test_out_cluster_handler_config( cluster = zigpy_dev.endpoints[1].out_clusters[cluster_id] cluster.bind_only = True cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, cluster_handlers.ClusterHandler - ) + cluster_id, {None: cluster_handlers.ClusterHandler} + ).get(None) cluster_handler = cluster_handler_class(cluster, endpoint) await cluster_handler.async_configure() @@ -334,13 +342,46 @@ async def test_out_cluster_handler_config( def test_cluster_handler_registry() -> None: """Test ZIGBEE cluster handler Registry.""" + + # get all quirk ID from zigpy quirks registry + all_quirk_ids = {} + for cluster_id in CLUSTERS_BY_ID: + all_quirk_ids[cluster_id] = {None} + for manufacturer in zigpy_quirks._DEVICE_REGISTRY.registry.values(): + for model_quirk_list in manufacturer.values(): + for quirk in model_quirk_list: + quirk_id = getattr(quirk, zha_const.ATTR_QUIRK_ID, None) + device_description = getattr(quirk, "replacement", None) or getattr( + quirk, "signature", None + ) + + for endpoint in device_description["endpoints"].values(): + cluster_ids = set() + if "input_clusters" in endpoint: + cluster_ids.update(endpoint["input_clusters"]) + if "output_clusters" in endpoint: + cluster_ids.update(endpoint["output_clusters"]) + for cluster_id in cluster_ids: + if not isinstance(cluster_id, int): + cluster_id = cluster_id.cluster_id + if cluster_id not in all_quirk_ids: + all_quirk_ids[cluster_id] = {None} + all_quirk_ids[cluster_id].add(quirk_id) + + del quirk, model_quirk_list, manufacturer + for ( cluster_id, - cluster_handler, + cluster_handler_classes, ) in registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.items(): assert isinstance(cluster_id, int) assert 0 <= cluster_id <= 0xFFFF - assert issubclass(cluster_handler, cluster_handlers.ClusterHandler) + assert cluster_id in all_quirk_ids + assert isinstance(cluster_handler_classes, dict) + for quirk_id, cluster_handler in cluster_handler_classes.items(): + assert isinstance(quirk_id, NoneType) or isinstance(quirk_id, str) + assert issubclass(cluster_handler, cluster_handlers.ClusterHandler) + assert quirk_id in all_quirk_ids[cluster_id] def test_epch_unclaimed_cluster_handlers(cluster_handler) -> None: @@ -731,7 +772,7 @@ async def test_zll_device_groups( mock.MagicMock(), ) async def test_cluster_no_ep_attribute( - zha_device_mock: Callable[..., ZHADevice] + zha_device_mock: Callable[..., ZHADevice], ) -> None: """Test cluster handlers for clusters without ep_attribute.""" @@ -818,7 +859,8 @@ class TestZigbeeClusterHandler(cluster_handlers.ClusterHandler): ], ) - mock_zha_device = mock.AsyncMock(spec_set=ZHADevice) + mock_zha_device = mock.AsyncMock(spec=ZHADevice) + mock_zha_device.quirk_id = None zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) # The cluster handler throws an error when matching this cluster @@ -827,14 +869,84 @@ class TestZigbeeClusterHandler(cluster_handlers.ClusterHandler): # And one is also logged at runtime with patch.dict( - registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY, - {cluster.cluster_id: TestZigbeeClusterHandler}, + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {None: TestZigbeeClusterHandler}, ), caplog.at_level(logging.WARNING): zha_endpoint.add_all_cluster_handlers() assert "missing_attr" in caplog.text +async def test_standard_cluster_handler(hass: HomeAssistant, caplog) -> None: + """Test setting up a cluster handler that matches a standard cluster.""" + + class TestZigbeeClusterHandler(ColorClusterHandler): + pass + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec=ZHADevice) + mock_zha_device.quirk_id = None + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + with patch.dict( + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {"__test_quirk_id": TestZigbeeClusterHandler}, + ): + zha_endpoint.add_all_cluster_handlers() + + assert len(zha_endpoint.all_cluster_handlers) == 1 + assert isinstance( + list(zha_endpoint.all_cluster_handlers.values())[0], ColorClusterHandler + ) + + +async def test_quirk_id_cluster_handler(hass: HomeAssistant, caplog) -> None: + """Test setting up a cluster handler that matches a standard cluster.""" + + class TestZigbeeClusterHandler(ColorClusterHandler): + pass + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec=ZHADevice) + mock_zha_device.quirk_id = "__test_quirk_id" + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + with patch.dict( + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {"__test_quirk_id": TestZigbeeClusterHandler}, + ): + zha_endpoint.add_all_cluster_handlers() + + assert len(zha_endpoint.all_cluster_handlers) == 1 + assert isinstance( + list(zha_endpoint.all_cluster_handlers.values())[0], TestZigbeeClusterHandler + ) + + # parametrize side effects: @pytest.mark.parametrize( ("side_effect", "expected_error"), diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 9ec8048ea0339c..883df4aba94f57 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -10,7 +10,7 @@ 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.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 737604482d881b..7d45960d576aef 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 2a0a241c864027..4f5209207046ee 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -4,22 +4,21 @@ 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 = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) - 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) + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_new: + await zha_gateway.async_initialize() - await zha_gateway.async_initialize() + mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state - RadioType.ezsp.controller.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 ad6ab4e351e0d3..c2e9469c23968d 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 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 1ec70b74735389..bd799187a19728 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -40,7 +40,10 @@ ) 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_registries.py b/tests/components/zha/test_registries.py index 68ff116adead25..80845cf9866f17 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -585,18 +585,18 @@ def quirk_class_validator(value): def test_entity_names() -> None: """Make sure that all handlers expose entities with valid names.""" - for _, entities in iter_all_rules(): - for entity in entities: - if hasattr(entity, "_attr_name"): + for _, entity_classes in iter_all_rules(): + for entity_class in entity_classes: + if hasattr(entity_class, "__attr_name"): # The entity has a name - assert isinstance(entity._attr_name, str) and entity._attr_name - elif hasattr(entity, "_attr_translation_key"): + assert (name := entity_class.__attr_name) and isinstance(name, str) + elif hasattr(entity_class, "__attr_translation_key"): assert ( - isinstance(entity._attr_translation_key, str) - and entity._attr_translation_key + isinstance(entity_class.__attr_translation_key, str) + and entity_class.__attr_translation_key ) - elif hasattr(entity, "_attr_device_class"): - assert entity._attr_device_class + elif hasattr(entity_class, "__attr_device_class"): + assert entity_class.__attr_device_class else: # The only exception (for now) is IASZone - assert entity is IASZone + assert entity_class is IASZone diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index 9c79578843c947..0efff5ecb526f1 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -175,7 +175,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.SETUP_ERROR + assert config_entry.state == ConfigEntryState.SETUP_RETRY await hass.config_entries.async_unload(config_entry.entry_id) @@ -312,6 +312,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 +390,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_sensor.py b/tests/components/zha/test_sensor.py index 7c11077c55d44e..d9a61b12357d12 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -1,4 +1,5 @@ """Test ZHA sensor.""" +from datetime import timedelta import math from unittest.mock import MagicMock, patch @@ -47,7 +48,10 @@ ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE -from tests.common import async_mock_load_restore_state_from_storage +from tests.common import ( + async_fire_time_changed, + async_mock_load_restore_state_from_storage, +) ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}" @@ -260,11 +264,12 @@ async def async_test_powerconfiguration2(hass, cluster, entity_id): """Test powerconfiguration/battery sensor.""" await send_attributes_report(hass, cluster, {33: -1}) assert_state(hass, entity_id, STATE_UNKNOWN, "%") - assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.9 - assert hass.states.get(entity_id).attributes["battery_quantity"] == 3 - assert hass.states.get(entity_id).attributes["battery_size"] == "AAA" - await send_attributes_report(hass, cluster, {32: 20}) - assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.0 + + await send_attributes_report(hass, cluster, {33: 255}) + assert_state(hass, entity_id, STATE_UNKNOWN, "%") + + await send_attributes_report(hass, cluster, {33: 98}) + assert_state(hass, entity_id, "49", "%") async def async_test_device_temperature(hass, cluster, entity_id): @@ -920,6 +925,44 @@ async def test_elec_measurement_sensor_type( assert state.attributes["measurement_type"] == expected_type +async def test_elec_measurement_sensor_polling( + hass: HomeAssistant, + elec_measurement_zigpy_dev, + zha_device_joined_restored, +) -> None: + """Test ZHA electrical measurement sensor polling.""" + + entity_id = ENTITY_ID_PREFIX.format("power") + zigpy_dev = elec_measurement_zigpy_dev + zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ + "active_power" + ] = 20 + + await zha_device_joined_restored(zigpy_dev) + + # test that the sensor has an initial state of 2.0 + state = hass.states.get(entity_id) + assert state.state == "2.0" + + # update the value for the power reading + zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ + "active_power" + ] = 60 + + # ensure the state is still 2.0 + state = hass.states.get(entity_id) + assert state.state == "2.0" + + # let the polling happen + future = dt_util.utcnow() + timedelta(seconds=90) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # ensure the state has been updated to 6.0 + state = hass.states.get(entity_id) + assert state.state == "6.0" + + @pytest.mark.parametrize( "supported_attributes", ( diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index b07b34763d10d5..0db9b7dd18e066 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -197,6 +197,17 @@ async def test_switch( tsn=None, ) + await async_setup_component(hass, "homeassistant", {}) + + cluster.read_attributes.reset_mock() + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert len(cluster.read_attributes.mock_calls) == 1 + assert cluster.read_attributes.call_args == call( + ["on_off"], allow_cache=False, only_cache=False, manufacturer=None + ) + # test joining a new switch to the network and HA await async_test_rejoin(hass, zigpy_device, [cluster], (1,)) diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index d914c88c0c2903..44006ea6ca1dd9 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -62,7 +62,7 @@ ) 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 44f01555b19e79..65ef55c47112da 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -1492,7 +1492,7 @@ 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 @@ 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 @@ 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 @@ 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 5a424b38c5b8fa..f2c3abd362a464 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 00000000000000..ffc7b25fda404c --- /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/test_api.py b/tests/components/zwave_js/test_api.py index 9c4a6339a78ead..aa20bd3bb8456a 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 e9040dfd397e6c..e4550b7f961fe9 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -40,6 +40,7 @@ ATTR_TEMPERATURE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from .common import ( @@ -278,7 +279,7 @@ async def test_thermostat_v2( client.async_send_command.reset_mock() # Test setting invalid fan mode - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -415,6 +416,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: @@ -621,7 +693,7 @@ async def test_preset_and_no_setpoint( assert state.attributes[ATTR_TEMPERATURE] is None assert state.attributes[ATTR_PRESET_MODE] == "Full power" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): # Test setting invalid preset mode await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index ba0bbbe087d2ce..f9615c84e1ddef 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[1].data[ - "some" - ] == "event.value_notification.scene_activation2 - 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"] + == 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 cbaa27c2a91302..569e36d3b5cb1f 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 92141eec3ff0b7..c26a5366d37f70 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -536,13 +536,14 @@ async def get_percentage_from_zwave_speed(zwave_speed): 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 20b3380f530201..cfa0163add54e8 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -968,7 +968,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) @@ -980,7 +980,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 ) @@ -1761,6 +1761,7 @@ async def test_factory_reset_node( hass: HomeAssistant, client, multisensor_6, multisensor_6_state, integration ) -> None: """Test when a node is removed because it was reset.""" + dev_reg = dr.async_get(hass) # One config entry scenario remove_event = Event( type="node removed", @@ -1781,15 +1782,25 @@ async def test_factory_reset_node( assert notifications[msg_id]["message"].startswith("`Multisensor 6`") assert "with the home ID" not in notifications[msg_id]["message"] async_dismiss(hass, msg_id) + await hass.async_block_till_done() + assert not dev_reg.async_get_device(identifiers={dev_id}) # Add mock config entry to simulate having multiple entries new_entry = MockConfigEntry(domain=DOMAIN) new_entry.add_to_hass(hass) # Re-add the node then remove it again - client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( - client, deepcopy(multisensor_6_state) + add_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(multisensor_6_state), + "result": {}, + }, ) + client.driver.controller.receive_event(add_event) + await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) # Test case where config entry title and home ID don't match @@ -1797,16 +1808,24 @@ async def test_factory_reset_node( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert ( - "network `Mock Title`, with the home ID `3245146787`." + "network `Mock Title`, with the home ID `3245146787`" in notifications[msg_id]["message"] ) async_dismiss(hass, msg_id) # Test case where config entry title and home ID do match hass.config_entries.async_update_entry(integration, title="3245146787") - client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( - client, deepcopy(multisensor_6_state) + add_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(multisensor_6_state), + "result": {}, + }, ) + client.driver.controller.receive_event(add_event) + await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) notifications = async_get_persistent_notifications(hass) diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 5a5711d9dad281..2213e9cf069a58 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -15,10 +15,15 @@ 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 @@ 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 c63f0c429fd41e..1cbdb8799f3286 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_sensor.py b/tests/components/zwave_js/test_sensor.py index f00413b0d80656..0fe3e32043b434 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -165,7 +165,10 @@ async def test_invalid_multilevel_sensor_scale( async def test_energy_sensors( - hass: HomeAssistant, hank_binary_switch, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hank_binary_switch, + integration, ) -> None: """Test power and energy sensors.""" state = hass.states.get(POWER_SENSOR) @@ -179,7 +182,7 @@ async def test_energy_sensors( state = hass.states.get(ENERGY_SENSOR) assert state - assert state.state == "0.16" + assert state.state == "0.164" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING @@ -187,10 +190,17 @@ async def test_energy_sensors( state = hass.states.get(VOLTAGE_SENSOR) assert state - assert state.state == "122.96" + assert state.state == "122.963" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfElectricPotential.VOLT assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.VOLTAGE + entity_entry = entity_registry.async_get(VOLTAGE_SENSOR) + + assert entity_entry is not None + sensor_options = entity_entry.options.get("sensor") + assert sensor_options is not None + assert sensor_options["suggested_display_precision"] == 0 + state = hass.states.get(CURRENT_SENSOR) assert state diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 25553489b4ed84..26b9459cfc2f30 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -272,7 +272,7 @@ def clear_events(): clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) @@ -834,7 +834,7 @@ def clear_events(): clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) diff --git a/tests/conftest.py b/tests/conftest.py index 09ad70bfcf1b42..ea4ddd23d28888 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,6 @@ from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant, legacy_api_password from homeassistant.components.device_tracker.legacy import Device -from homeassistant.components.network.models import Adapter, IPv4ConfiguredAddress from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, @@ -383,7 +382,7 @@ def reset_hass_threading_local_object() -> Generator[None, None, None]: ha._hass.__dict__.clear() -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def bcrypt_cost() -> Generator[None, None, None]: """Run with reduced rounds during tests, to speed up uses.""" import bcrypt @@ -488,6 +487,8 @@ async def go( if isinstance(__param, Application): server_kwargs = server_kwargs or {} server = TestServer(__param, loop=loop, **server_kwargs) + # Registering a view after starting the server should still work. + server.app._router.freeze = lambda: None client = CoalescingClient(server, loop=loop, **kwargs) elif isinstance(__param, BaseTestServer): client = TestClient(__param, loop=loop, **kwargs) @@ -972,7 +973,7 @@ async def _mqtt_mock_entry( mock_mqtt_instance = None async def _setup_mqtt_entry( - setup_entry: Callable[[HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool]] + setup_entry: Callable[[HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool]], ) -> MagicMock: """Set up the MQTT config entry.""" assert await setup_entry(hass, entry) @@ -1096,21 +1097,18 @@ async def _setup_mqtt_entry() -> MqttMockHAClient: yield _setup_mqtt_entry -@pytest.fixture(autouse=True) +@pytest.fixture(autouse=True, scope="session") def mock_network() -> Generator[None, None, None]: """Mock network.""" - mock_adapter = Adapter( - name="eth0", - index=0, - enabled=True, - auto=True, - default=True, - ipv4=[IPv4ConfiguredAddress(address="10.10.10.10", network_prefix=24)], - ipv6=[], - ) with patch( - "homeassistant.components.network.network.async_load_adapters", - return_value=[mock_adapter], + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[ + Mock( + nice_name="eth0", + ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], + index=0, + ) + ], ): yield @@ -1133,7 +1131,7 @@ def mock_zeroconf() -> Generator[None, None, None]: with patch( "homeassistant.components.zeroconf.HaZeroconf", autospec=True ) as mock_zc, patch( - "homeassistant.components.zeroconf.HaAsyncServiceBrowser", autospec=True + "homeassistant.components.zeroconf.AsyncServiceBrowser", autospec=True ): zc = mock_zc.return_value # DNSCache has strong Cython type checks, and MagicMock does not work @@ -1145,13 +1143,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 @@ -1538,10 +1542,10 @@ async def mock_enable_bluetooth( await hass.async_block_till_done() -@pytest.fixture +@pytest.fixture(scope="session") def mock_bluetooth_adapters() -> Generator[None, None, None]: """Fixture to mock bluetooth adapters.""" - with patch( + with patch("bluetooth_auto_recovery.recover_adapter"), patch( "bluetooth_adapters.systems.platform.system", return_value="Linux" ), patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", @@ -1568,14 +1572,14 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # Late imports to avoid loading bleak unless we need it # pylint: disable-next=import-outside-toplevel - from homeassistant.components.bluetooth import scanner as bluetooth_scanner + from habluetooth import scanner as bluetooth_scanner # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called # when EVENT_HOMEASSISTANT_STOP is fired. bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ) as mock_bleak_scanner_start: yield mock_bleak_scanner_start 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 00000000000000..49db89f45baf3a --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic/configuration.yaml @@ -0,0 +1,63 @@ +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: + +# Invalid domains +"iot_domain ": +"": +5: 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 00000000000000..8e1c75c3511573 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml @@ -0,0 +1,13 @@ +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 +"iot_domain ": !include integrations/iot_domain .yaml +"": !include integrations/.yaml +5: !include integrations/5.yaml diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml new file mode 100644 index 00000000000000..e69de29bb2d1d6 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 00000000000000..d246d73c257576 --- /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 00000000000000..8b592b01e2d238 --- /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 00000000000000..c3b2edb3f947e8 --- /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 00000000000000..e8dcd8f4017e80 --- /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 00000000000000..0cda3d04a55b45 --- /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 00000000000000..12d6d869f355b3 --- /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 00000000000000..7af4b20c0161a5 --- /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 00000000000000..d246d73c257576 --- /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 00000000000000..8b592b01e2d238 --- /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 00000000000000..e69de29bb2d1d6 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 00000000000000..dd592194f1acd4 --- /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 00000000000000..bb0f052a39ab05 --- /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/invalid_domains/.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml new file mode 100644 index 00000000000000..e69de29bb2d1d6 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 00000000000000..b17f61062089a7 --- /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 00000000000000..f6c3219741eb3e --- /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 00000000000000..2265e8c2f07f53 --- /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 00000000000000..53f220472e25f9 --- /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 00000000000000..b0fec6d5046de6 --- /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 00000000000000..e0c03e9f4454c7 --- /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 00000000000000..172f96e2da2614 --- /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 00000000000000..f8ef2b5643b997 --- /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 00000000000000..25d734b126ab92 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages/configuration.yaml @@ -0,0 +1,77 @@ +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: + # Invalid domains + pack_iot_domain_space: + "iot_domain ": + pack_empty: + "": + pack_5: + 5: 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 00000000000000..d3b52e4d49d9ed --- /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 00000000000000..c07a9434f82dc7 --- /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 00000000000000..0f96654008e07e --- /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 00000000000000..1ad33e67171b56 --- /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 00000000000000..b5d4602c683c82 --- /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 00000000000000..fad2c53d527c09 --- /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 00000000000000..2e17b76680017f --- /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 00000000000000..213c3ea03f8fc4 --- /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 00000000000000..257ff66d10bc97 --- /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 00000000000000..59a240defaf5a6 --- /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 00000000000000..e137411b0fcd5e --- /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/component_validation/packages_include_dir_named/integrations/pack_5.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml new file mode 100644 index 00000000000000..70bf80a6b648b0 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml @@ -0,0 +1 @@ +5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml new file mode 100644 index 00000000000000..510d4682445c12 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml @@ -0,0 +1 @@ +"": diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml new file mode 100644 index 00000000000000..49b5720a5361d8 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml @@ -0,0 +1 @@ +"iot_domain ": 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 00000000000000..19ec6e1e98320b --- /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 00000000000000..85ffc610758467 --- /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 00000000000000..09cbdaa1bf8e7f --- /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 00000000000000..c1ab9d84c487f7 --- /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 00000000000000..1b524ae6ec175f --- /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 00000000000000..5e28092d6c017e --- /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 00000000000000..d041b77ea298bd --- /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 00000000000000..bf2a79c13074af --- /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 00000000000000..d3b52e4d49d9ed --- /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 00000000000000..66a70375f702fb --- /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 00000000000000..86292e7ab963a2 --- /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 00000000000000..7b343d41e9a527 --- /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 00000000000000..4e01fecc74cb51 --- /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 00000000000000..bb0f052a39ab05 --- /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 00000000000000..5c01bd1b3c1d2f --- /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 00000000000000..e0c03e9f4454c7 --- /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 00000000000000..4e01fecc74cb51 --- /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 00000000000000..d3b52e4d49d9ed --- /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 00000000000000..f9f2f6e7319681 --- /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/fixtures/feedreader6.xml b/tests/fixtures/feedreader6.xml new file mode 100644 index 00000000000000..621c89787e82ef --- /dev/null +++ b/tests/fixtures/feedreader6.xml @@ -0,0 +1,27 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +0000 + Mon, 30 Apr 2018 15:00:00 +0000 + 1800 + + + Title 1 + Description 1 + http://www.example.com/link/1 + GUID 1 + Mon, 30 Apr 2018 15:10:00 +0000 + + + Title 2 + Description 2 + http://www.example.com/link/2 + GUID 2 + Mon, 30 Apr 2018 15:10:00 +0000 + + + + diff --git a/tests/helpers/snapshots/test_entity.ambr b/tests/helpers/snapshots/test_entity.ambr new file mode 100644 index 00000000000000..70f86feaf79017 --- /dev/null +++ b/tests/helpers/snapshots/test_entity.ambr @@ -0,0 +1,352 @@ +# serializer version: 1 +# name: test_entity_description_as_dataclass + dict({ + 'device_class': 'test', + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': , + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_description_as_dataclass.1 + "EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, translation_placeholders=None, unit_of_measurement=None)" +# --- +# name: test_extending_entity_description + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.1 + "test_extending_entity_description..FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.10 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.11 + "test_extending_entity_description..ComplexEntityDescription1C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.12 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.13 + "test_extending_entity_description..ComplexEntityDescription1D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.14 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.15 + "test_extending_entity_description..ComplexEntityDescription2A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.16 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.17 + "test_extending_entity_description..ComplexEntityDescription2B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.18 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.19 + "test_extending_entity_description..ComplexEntityDescription2C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.2 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.20 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.21 + "test_extending_entity_description..ComplexEntityDescription2D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.22 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.23 + "test_extending_entity_description..ComplexEntityDescription3A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.24 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.25 + "test_extending_entity_description..ComplexEntityDescription3B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.26 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.27 + "test_extending_entity_description..ComplexEntityDescription4A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.28 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.29 + "test_extending_entity_description..ComplexEntityDescription4B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.3 + "test_extending_entity_description..ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.30 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.31 + "test_extending_entity_description..CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None)" +# --- +# name: test_extending_entity_description.4 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extension': 'ext', + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.5 + "test_extending_entity_description..MyExtendedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extension='ext', extra='foo')" +# --- +# name: test_extending_entity_description.6 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.7 + "test_extending_entity_description..ComplexEntityDescription1A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.8 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'translation_placeholders': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.9 + "test_extending_entity_description..ComplexEntityDescription1B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" +# --- diff --git a/tests/helpers/test_aiohttp_compat.py b/tests/helpers/test_aiohttp_compat.py deleted file mode 100644 index 749984dbc2ed0a..00000000000000 --- 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 38c1b4913cd8e2..de57fa0a8f34e5 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -7,6 +7,7 @@ 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, @@ -81,9 +82,8 @@ async def test_bad_core_config(hass: HomeAssistant) -> None: error = CheckConfigError( ( - "Invalid config for [homeassistant]: not a valid value for dictionary " - "value @ data['unit_system']. Got 'bad'. (See " - f"{hass.config.path(YAML_CONFIG_FILE)}, line 2). " + 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"}, @@ -103,8 +103,8 @@ async def test_config_platform_valid(hass: HomeAssistant) -> None: _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): @@ -118,8 +118,8 @@ async def test_component_platform_not_found(hass: HomeAssistant) -> None: _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( @@ -141,8 +141,8 @@ async def test_component_requirement_not_found(hass: HomeAssistant) -> None: _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 @@ -154,8 +154,8 @@ async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: _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 @@ -167,8 +167,8 @@ async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: _assert_warnings_errors(res, [], []) -async def test_component_import_error(hass: HomeAssistant) -> None: - """Test errors if component with a requirement not found 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( @@ -188,19 +188,19 @@ async def test_component_import_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("component", "errors", "warnings", "message"), + ("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]"), + ("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_component_schema_error( - hass: HomeAssistant, component: str, errors: int, warnings: int, message: str +async def test_integration_schema_error( + hass: HomeAssistant, integration: str, errors: int, warnings: int, message: str ) -> None: - """Test schema error in component.""" + """Test schema error in integration.""" # Make sure they don't exist - files = {YAML_CONFIG_FILE: BASE_CONFIG + f"frontend:\n{component}:\n blah:"} + 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) @@ -215,8 +215,8 @@ async def test_component_schema_error( assert message in warn.message -async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: - """Test errors if component or platform not found.""" +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): @@ -227,7 +227,12 @@ async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: assert res["light"] == [] warning = CheckConfigError( - "Platform error light.beer - Integration 'beer' not found.", None, None + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), + None, + None, ) _assert_warnings_errors(res, [warning], []) @@ -274,33 +279,33 @@ async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: ( "blah:\n - platform: test\n option1: 123", 1, - "Invalid config for [blah.test]: expected str for dictionary value", + "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, - "Invalid config for [blah.test]: expected str for dictionary value", + "expected str for dictionary value", {"old": "blah", "option1": 123, "platform": "test"}, ), # Test base platform configuration error ( "blah:\n - paltfrom: test\n", 1, - "Invalid config for [blah]: required key not provided", + "required key 'platform' not provided", {"paltfrom": "test"}, ), ], ) -async def test_component_platform_schema_error( +async def test_platform_schema_error( hass: HomeAssistant, extra_config: str, warnings: int, message: str | None, config: dict | None, ) -> None: - """Test schema error in component.""" + """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( @@ -328,7 +333,7 @@ async def test_component_platform_schema_error( assert warn.config == config -async def test_component_config_platform_import_error(hass: HomeAssistant) -> None: +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"} @@ -348,8 +353,8 @@ async def test_component_config_platform_import_error(hass: HomeAssistant) -> No _assert_warnings_errors(res, [], [error]) -async def test_component_platform_import_error(hass: HomeAssistant) -> None: - """Test errors if component or platform not found.""" +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( @@ -361,7 +366,7 @@ async def test_component_platform_import_error(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} warning = CheckConfigError( - "Platform error light.demo - blablabla", + "Platform error 'light' from integration 'demo' - blablabla", None, None, ) @@ -379,8 +384,8 @@ async def test_package_invalid(hass: HomeAssistant) -> None: warning = CheckConfigError( ( - "Package p1 setup failed. Component group cannot be merged. Expected a " - "dict." + "Setup of package 'p1' failed: integration 'group' cannot be merged" + ", expected a dict" ), "homeassistant.packages.p1.group", {"group": ["a"]}, @@ -416,9 +421,7 @@ async def test_automation_config_platform(hass: HomeAssistant) -> None: 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 @@ -440,12 +443,35 @@ async def test_automation_config_platform(hass: HomeAssistant) -> None: 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 @@ -457,11 +483,11 @@ async def test_config_platform_raise(hass: HomeAssistant) -> None: with patch("os.path.isfile", return_value=True), patch_yaml_files(files): res = await async_check_ha_config_file(hass) error = CheckConfigError( - "Unexpected error calling config validator: Broken", + message, "bla", {"value": 1}, ) - _assert_warnings_errors(res, [], [error]) + _assert_warnings_errors(res, [error] * warnings, [error] * errors) async def test_removed_yaml_support(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 3b8217028cc613..bcb6f4fa971fd1 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import AsyncMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -1137,7 +1138,7 @@ async def test_state_for(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -1163,7 +1164,7 @@ async def test_state_for_template(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -2235,7 +2236,7 @@ async def test_if_action_before_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2247,7 +2248,7 @@ async def test_if_action_before_sunrise_no_offset( # now = sunrise -> 'before sunrise' true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2259,7 +2260,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight -> 'before sunrise' true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2271,7 +2272,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2306,7 +2307,7 @@ async def test_if_action_after_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2318,7 +2319,7 @@ async def test_if_action_after_sunrise_no_offset( # now = sunrise + 1s -> 'after sunrise' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2330,7 +2331,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2342,7 +2343,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2381,7 +2382,7 @@ async def test_if_action_before_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2393,7 +2394,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunrise + 1h -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2405,7 +2406,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2417,7 +2418,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2429,7 +2430,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2441,7 +2442,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2453,7 +2454,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2465,7 +2466,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2504,7 +2505,7 @@ async def test_if_action_before_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = local midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2516,7 +2517,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2528,7 +2529,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1h -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2540,7 +2541,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2552,7 +2553,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight - 1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2564,7 +2565,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2576,7 +2577,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2588,7 +2589,7 @@ async def test_if_action_before_sunset_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2627,7 +2628,7 @@ async def test_if_action_after_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2639,7 +2640,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunrise + 1h -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 58, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2651,7 +2652,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2663,7 +2664,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2675,7 +2676,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2687,7 +2688,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon - 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2699,7 +2700,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2711,7 +2712,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset + 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2723,7 +2724,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2735,7 +2736,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2774,7 +2775,7 @@ async def test_if_action_after_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2786,7 +2787,7 @@ async def test_if_action_after_sunset_with_offset( # now = sunset + 1h -> 'after sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2798,7 +2799,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight-1s -> 'after sunset' with offset +1h true now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2810,7 +2811,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2849,7 +2850,7 @@ async def test_if_action_after_and_before_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2865,7 +2866,7 @@ async def test_if_action_after_and_before_during( # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2877,7 +2878,7 @@ async def test_if_action_after_and_before_during( # now = sunrise + 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2893,7 +2894,7 @@ async def test_if_action_after_and_before_during( # now = sunset - 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2909,7 +2910,7 @@ async def test_if_action_after_and_before_during( # now = 9AM local -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2952,7 +2953,7 @@ async def test_if_action_before_or_after_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2968,7 +2969,7 @@ async def test_if_action_before_or_after_during( # now = sunset + 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2984,7 +2985,7 @@ async def test_if_action_before_or_after_during( # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3000,7 +3001,7 @@ async def test_if_action_before_or_after_during( # now = sunset - 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3016,7 +3017,7 @@ async def test_if_action_before_or_after_during( # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -3032,7 +3033,7 @@ async def test_if_action_before_or_after_during( # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -3077,7 +3078,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3089,7 +3090,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'before sunrise' true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3101,7 +3102,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3113,7 +3114,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3154,7 +3155,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise -> 'after sunrise' true now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3166,7 +3167,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'after sunrise' not true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3178,7 +3179,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3190,7 +3191,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3231,7 +3232,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset + 1s -> 'before sunset' not true now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3243,7 +3244,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = sunset - 1h-> 'before sunset' true now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3255,7 +3256,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3267,7 +3268,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3308,7 +3309,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset -> 'after sunset' true now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3320,7 +3321,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = sunset - 1s -> 'after sunset' not true now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3332,7 +3333,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight -> 'after sunset' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3344,7 +3345,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunset' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 90d8030be79a28..71c81b096cafa8 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -9,12 +9,7 @@ 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_validation.py b/tests/helpers/test_config_validation.py index a9ddd89a0b36f8..f997e3a6c10cfc 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -539,6 +539,13 @@ def test_string(hass: HomeAssistant) -> None: for value in (True, 1, "hello"): schema(value) + # Test subclasses of str are returned + class MyString(str): + pass + + my_string = MyString("hello") + assert schema(my_string) is my_string + # Test template support for text, native in ( ("[1, 2]", [1, 2]), @@ -832,6 +839,7 @@ def test_selector_in_serializer() -> None: "selector": { "text": { "multiline": False, + "multiple": False, } } } @@ -1623,3 +1631,19 @@ def test_platform_only_schema( cv.platform_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) assert expected_message in caplog.text assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + + +def test_domain() -> None: + """Test domain.""" + with pytest.raises(vol.Invalid): + cv.domain_key(5) + with pytest.raises(vol.Invalid): + cv.domain_key("") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + assert cv.domain_key("hue") == "hue" + assert cv.domain_key("hue1") == "hue1" + assert cv.domain_key("hue 1") == "hue" + assert cv.domain_key("hue 1") == "hue" diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 1216bd6e293300..017e541bb08e4f 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -1,15 +1,24 @@ """Test deprecation helpers.""" +from enum import StrEnum +import logging +import sys +from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + check_if_deprecated_constant, deprecated_class, deprecated_function, deprecated_substitute, + dir_with_deprecated_constants, get_deprecated, ) +from homeassistant.helpers.frame import MissingIntegrationFrame from tests.common import MockModule, mock_integration @@ -119,32 +128,52 @@ def test_deprecated_class(mock_get_logger) -> None: assert len(mock_logger.warning.mock_calls) == 1 -def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) +def test_deprecated_function( + caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, +) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is not known. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass mock_deprecated_function() assert ( - "mock_deprecated_function is a deprecated function. Use new_function instead" - in caplog.text - ) + f"mock_deprecated_function is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_built_in_integration( caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is built-in. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -170,14 +199,24 @@ def mock_deprecated_function(): ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead" in caplog.text - ) - - + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text + + +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_custom_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. @@ -186,7 +225,7 @@ def test_deprecated_function_called_from_custom_integration( mock_integration(hass, MockModule("hue"), built_in=False) - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -212,7 +251,196 @@ def mock_deprecated_function(): ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead, please report it to the author of the 'hue' custom " - "integration" in caplog.text + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead, please report it to the author of the " + "'hue' custom integration" + ) in caplog.text + + +class TestDeprecatedConstantEnum(StrEnum): + """Test deprecated constant enum.""" + + __test__ = False # prevent test collection of class by pytest + + TEST = "value" + + +def _get_value(obj: DeprecatedConstant | DeprecatedConstantEnum | tuple) -> Any: + if isinstance(obj, tuple): + if len(obj) == 2: + return obj[0].value + + return obj[0] + + if isinstance(obj, DeprecatedConstant): + return obj.value + + if isinstance(obj, DeprecatedConstantEnum): + return obj.enum.value + + +@pytest.mark.parametrize( + ("deprecated_constant", "extra_msg"), + [ + ( + DeprecatedConstant("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, None), + ". Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, "2099.1"), + " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ), + ], +) +@pytest.mark.parametrize( + ("module_name", "extra_extra_msg"), + [ + ("homeassistant.components.hue.light", ""), # builtin integration + ( + "config.custom_components.hue.light", + ", please report it to the author of the 'hue' custom integration", + ), # custom component integration + ], +) +def test_check_if_deprecated_constant( + caplog: pytest.LogCaptureFixture, + deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum | tuple, + extra_msg: str, + module_name: str, + extra_extra_msg: str, +) -> None: + """Test check_if_deprecated_constant.""" + module_globals = { + "__name__": module_name, + "_DEPRECATED_TEST_CONSTANT": deprecated_constant, + } + filename = f"/home/paulus/{module_name.replace('.', '/')}.py" + + # mock sys.modules for homeassistant/helpers/frame.py#get_integration_frame + with patch.dict(sys.modules, {module_name: Mock(__file__=filename)}), patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename=filename, + lineno="23", + line="await session.close()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + value = check_if_deprecated_constant("TEST_CONSTANT", module_globals) + assert value == _get_value(deprecated_constant) + + assert ( + module_name, + logging.WARNING, + f"TEST_CONSTANT was used from hue, this is a deprecated constant{extra_msg}{extra_extra_msg}", + ) in caplog.record_tuples + + +@pytest.mark.parametrize( + ("deprecated_constant", "extra_msg"), + [ + ( + DeprecatedConstant("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, None), + ". Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, "2099.1"), + " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ), + ], +) +@pytest.mark.parametrize( + ("module_name"), + [ + "homeassistant.components.hue.light", # builtin integration + "config.custom_components.hue.light", # custom component integration + ], +) +def test_check_if_deprecated_constant_integration_not_found( + caplog: pytest.LogCaptureFixture, + deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum | tuple, + extra_msg: str, + module_name: str, +) -> None: + """Test check_if_deprecated_constant.""" + module_globals = { + "__name__": module_name, + "_DEPRECATED_TEST_CONSTANT": deprecated_constant, + } + + with patch( + "homeassistant.helpers.frame.extract_stack", side_effect=MissingIntegrationFrame + ): + value = check_if_deprecated_constant("TEST_CONSTANT", module_globals) + assert value == _get_value(deprecated_constant) + + assert ( + module_name, + logging.WARNING, + f"TEST_CONSTANT is a deprecated constant{extra_msg}", + ) not in caplog.record_tuples + + +def test_test_check_if_deprecated_constant_invalid( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test check_if_deprecated_constant will raise an attribute error and create an log entry on an invalid deprecation type.""" + module_name = "homeassistant.components.hue.light" + module_globals = {"__name__": module_name, "_DEPRECATED_TEST_CONSTANT": 1} + name = "TEST_CONSTANT" + + excepted_msg = ( + f"Value of _DEPRECATED_{name} is an instance of " + "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" ) + + with pytest.raises(AttributeError, match=excepted_msg): + check_if_deprecated_constant(name, module_globals) + + assert (module_name, logging.DEBUG, excepted_msg) in caplog.record_tuples + + +@pytest.mark.parametrize( + ("module_global", "expected"), + [ + ({"CONSTANT": 1}, ["CONSTANT"]), + ({"_DEPRECATED_CONSTANT": 1}, ["_DEPRECATED_CONSTANT", "CONSTANT"]), + ( + {"_DEPRECATED_CONSTANT": 1, "SOMETHING": 2}, + ["_DEPRECATED_CONSTANT", "SOMETHING", "CONSTANT"], + ), + ], +) +def test_dir_with_deprecated_constants( + module_global: dict[str, Any], expected: list[str] +) -> None: + """Test dir() with deprecated constants.""" + assert dir_with_deprecated_constants(module_global) == expected diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 657d8871e6669b..43540a52f7daba 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -17,7 +17,11 @@ entity_registry as er, ) -from tests.common import MockConfigEntry, flush_store +from tests.common import ( + MockConfigEntry, + flush_store, + import_and_test_deprecated_constant_enum, +) @pytest.fixture @@ -2012,3 +2016,12 @@ async def test_loading_invalid_configuration_url_from_storage( identifiers={("serial", "123456ABCDEF")}, ) assert entry.configuration_url == "invalid" + + +@pytest.mark.parametrize(("enum"), list(dr.DeviceEntryDisabler)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: dr.DeviceEntryDisabler, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, dr, enum, "DISABLED_", "2025.1") diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index 2900cb2c09e173..d73bfe84607313 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -9,12 +9,7 @@ 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 @@ def setup_platform(hass, config, add_entities_callback, discovery_info=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_dispatcher.py b/tests/helpers/test_dispatcher.py index a251b20b0f41ad..89d23fb4533cfc 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -144,8 +144,6 @@ def bad_handler(*args): # wrap in partial to test message logging. async_dispatcher_connect(hass, "test", partial(bad_handler)) async_dispatcher_send(hass, "test", "bad") - await hass.async_block_till_done() - await hass.async_block_till_done() assert ( f"Exception in functools.partial({bad_handler}) when dispatching 'test': ('bad',)" @@ -153,6 +151,25 @@ def bad_handler(*args): ) +@pytest.mark.no_fail_on_log_exception +async def test_coro_exception_gets_logged( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test exception raised by signal handler.""" + + async def bad_async_handler(*args): + """Record calls.""" + raise Exception("This is a bad message in a coro") + + # wrap in partial to test message logging. + async_dispatcher_connect(hass, "test", bad_async_handler) + async_dispatcher_send(hass, "test", "bad") + await hass.async_block_till_done() + + assert "bad_async_handler" in caplog.text + assert "when dispatching 'test': ('bad',)" in caplog.text + + async def test_dispatcher_add_dispatcher(hass: HomeAssistant) -> None: """Test adding a dispatcher from a dispatcher.""" calls = [] diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 373dfac0cea90c..dd26b947f6732e 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -3,14 +3,18 @@ from collections.abc import Iterable import dataclasses from datetime import timedelta +from enum import IntFlag import logging import threading from typing import Any from unittest.mock import MagicMock, PropertyMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, @@ -29,7 +33,6 @@ MockEntityPlatform, MockModule, MockPlatform, - get_test_home_assistant, mock_integration, mock_registry, ) @@ -59,6 +62,17 @@ def test_generate_entity_id_given_keys() -> None: ) +async def test_generate_entity_id_given_hass(hass: HomeAssistant) -> None: + """Test generating an entity id given hass object.""" + hass.states.async_set("test.overwrite_hidden_true", "test") + + fmt = "test.{}" + assert ( + entity.generate_entity_id(fmt, "overwrite hidden true", hass=hass) + == "test.overwrite_hidden_true_2" + ) + + async def test_async_update_support(hass: HomeAssistant) -> None: """Test async update getting called.""" sync_update = [] @@ -93,40 +107,19 @@ async def async_update_func(): assert len(async_update) == 1 -class TestHelpersEntity: - """Test homeassistant.helpers.entity module.""" - - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.entity = entity.Entity() - self.entity.entity_id = "test.overwrite_hidden_true" - self.hass = self.entity.hass = get_test_home_assistant() - self.entity.schedule_update_ha_state() - self.hass.block_till_done() - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - - def test_generate_entity_id_given_hass(self): - """Test generating an entity id given hass object.""" - fmt = "test.{}" - assert ( - entity.generate_entity_id(fmt, "overwrite hidden true", hass=self.hass) - == "test.overwrite_hidden_true_2" - ) +async def test_device_class(hass: HomeAssistant) -> None: + """Test device class attribute.""" + ent = entity.Entity() + ent.entity_id = "test.overwrite_hidden_true" + ent.hass = hass + ent.async_write_ha_state() + state = hass.states.get(ent.entity_id) + assert state.attributes.get(ATTR_DEVICE_CLASS) is None - def test_device_class(self): - """Test device class attribute.""" - state = self.hass.states.get(self.entity.entity_id) - assert state.attributes.get(ATTR_DEVICE_CLASS) is None - with patch( - "homeassistant.helpers.entity.Entity.device_class", new="test_class" - ): - self.entity.schedule_update_ha_state() - self.hass.block_till_done() - state = self.hass.states.get(self.entity.entity_id) - assert state.attributes.get(ATTR_DEVICE_CLASS) == "test_class" + ent._attr_device_class = "test_class" + ent.async_write_ha_state() + state = hass.states.get(ent.entity_id) + assert state.attributes.get(ATTR_DEVICE_CLASS) == "test_class" async def test_warn_slow_update( @@ -590,7 +583,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 +596,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]) @@ -664,10 +655,9 @@ async def test_set_context_expired(hass: HomeAssistant) -> None: """Test setting context.""" context = Context() - with patch.object( - entity.Entity, "context_recent_time", new_callable=PropertyMock - ) as recent: - recent.return_value = timedelta(seconds=-5) + with patch( + "homeassistant.helpers.entity.CONTEXT_RECENT_TIME", timedelta(seconds=-5) + ): ent = entity.Entity() ent.hass = hass ent.entity_id = "hello.world" @@ -841,13 +831,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" @@ -975,7 +958,7 @@ async def test_entity_description_fallback() -> None: ent_with_description = entity.Entity() ent_with_description.entity_description = entity.EntityDescription(key="test") - for field in dataclasses.fields(entity.EntityDescription): + for field in dataclasses.fields(entity.EntityDescription._dataclass): if field.name == "key": continue @@ -1154,6 +1137,203 @@ def _default_to_device_class_name(self) -> bool: ) +@pytest.mark.parametrize( + ( + "has_entity_name", + "translation_key", + "translations", + "placeholders", + "expected_friendly_name", + ), + ( + (False, None, None, None, "Entity Blu"), + (True, None, None, None, "Device Bla Entity Blu"), + ( + True, + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "English ent" + }, + }, + None, + "Device Bla English ent", + ), + ( + True, + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent" + }, + }, + {"placeholder": "special"}, + "Device Bla special English ent", + ), + ( + True, + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "English ent {placeholder}" + }, + }, + {"placeholder": "special"}, + "Device Bla English ent special", + ), + ), +) +async def test_entity_name_translation_placeholders( + hass: HomeAssistant, + has_entity_name: bool, + translation_key: str | None, + translations: dict[str, str] | None, + placeholders: dict[str, str] | None, + expected_friendly_name: str | None, +) -> None: + """Test friendly name when the entity name translation has placeholders.""" + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + ent = MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + ) + ent.entity_description = entity.EntityDescription( + "test", + has_entity_name=has_entity_name, + translation_key=translation_key, + name="Entity Blu", + ) + if placeholders is not None: + ent._attr_translation_placeholders = placeholders + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ): + await _test_friendly_name(hass, ent, expected_friendly_name) + + +@pytest.mark.parametrize( + ( + "translation_key", + "translations", + "placeholders", + "release_channel", + "expected_error", + ), + ( + ( + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent {2ndplaceholder}" + }, + }, + {"placeholder": "special"}, + "stable", + ( + "has translation placeholders '{'placeholder': 'special'}' which do " + "not match the name '{placeholder} English ent {2ndplaceholder}'" + ), + ), + ( + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent {2ndplaceholder}" + }, + }, + {"placeholder": "special"}, + "beta", + "HomeAssistantError: Missing placeholder '2ndplaceholder'", + ), + ( + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent" + }, + }, + None, + "stable", + ( + "has translation placeholders '{}' which do " + "not match the name '{placeholder} English ent'" + ), + ), + ), +) +async def test_entity_name_translation_placeholder_errors( + hass: HomeAssistant, + translation_key: str | None, + translations: dict[str, str] | None, + placeholders: dict[str, str] | None, + release_channel: str, + expected_error: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test entity name translation has placeholder issues.""" + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([ent]) + return True + + ent = MockEntity( + unique_id="qwer", + ) + ent.entity_description = entity.EntityDescription( + "test", + has_entity_name=True, + translation_key=translation_key, + name="Entity Blu", + ) + if placeholders is not None: + ent._attr_translation_placeholders = placeholders + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + caplog.clear() + + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ), patch( + "homeassistant.helpers.entity.get_release_channel", return_value=release_channel + ): + await entity_platform.async_setup_entry(config_entry) + + assert expected_error in caplog.text + + @pytest.mark.parametrize( ("has_entity_name", "entity_name", "expected_friendly_name"), ( @@ -1409,8 +1589,8 @@ async def test_translation_key(hass: HomeAssistant) -> None: assert mock_entity2.translation_key == "from_entity_description" -async def test_repr_using_stringify_state() -> None: - """Test that repr uses stringify state.""" +async def test_repr(hass) -> None: + """Test Entity.__repr__.""" class MyEntity(MockEntity): """Mock entity.""" @@ -1420,8 +1600,19 @@ def state(self): """Return the state.""" raise ValueError("Boom") - entity = MyEntity(entity_id="test.test", available=False) - assert str(entity) == "" + platform = MockEntityPlatform(hass, domain="hello") + my_entity = MyEntity(entity_id="test.test", available=False) + + # Not yet added + assert str(my_entity) == "" + + # Added + await platform.async_add_entities([my_entity]) + assert str(my_entity) == "" + + # Removed + await platform.async_remove_entity(my_entity.entity_id) + assert str(my_entity) == "" async def test_warn_using_async_update_ha_state( @@ -1666,3 +1857,575 @@ async def async_will_remove_from_hass(self): assert len(result) == 2 assert len(ent.added_calls) == 3 assert len(ent.remove_calls) == 2 + + +def test_entity_description_as_dataclass(snapshot: SnapshotAssertion): + """Test EntityDescription behaves like a dataclass.""" + + obj = entity.EntityDescription("blah", device_class="test") + with pytest.raises(dataclasses.FrozenInstanceError): + obj.name = "mutate" + with pytest.raises(dataclasses.FrozenInstanceError): + delattr(obj, "name") + + assert dataclasses.is_dataclass(obj) + assert obj == snapshot + assert obj == entity.EntityDescription("blah", device_class="test") + assert repr(obj) == snapshot + + +def test_extending_entity_description(snapshot: SnapshotAssertion): + """Test extending entity descriptions.""" + + @dataclasses.dataclass(frozen=True) + class FrozenEntityDescription(entity.EntityDescription): + extra: str = None + + obj = FrozenEntityDescription("blah", extra="foo", name="name") + assert obj == snapshot + assert obj == FrozenEntityDescription("blah", extra="foo", name="name") + assert repr(obj) == snapshot + + # Try mutating + with pytest.raises(dataclasses.FrozenInstanceError): + obj.name = "mutate" + with pytest.raises(dataclasses.FrozenInstanceError): + delattr(obj, "name") + + @dataclasses.dataclass + class ThawedEntityDescription(entity.EntityDescription): + extra: str = None + + obj = ThawedEntityDescription("blah", extra="foo", name="name") + assert obj == snapshot + assert obj == ThawedEntityDescription("blah", extra="foo", name="name") + assert repr(obj) == snapshot + + # Try mutating + obj.name = "mutate" + assert obj.name == "mutate" + delattr(obj, "key") + assert not hasattr(obj, "key") + + # Try multiple levels of FrozenOrThawed + class ExtendedEntityDescription(entity.EntityDescription, frozen_or_thawed=True): + extension: str = None + + @dataclasses.dataclass(frozen=True) + class MyExtendedEntityDescription(ExtendedEntityDescription): + extra: str = None + + obj = MyExtendedEntityDescription("blah", extension="ext", extra="foo", name="name") + assert obj == snapshot + assert obj == MyExtendedEntityDescription( + "blah", extension="ext", extra="foo", name="name" + ) + assert repr(obj) == snapshot + + # Try multiple direct parents + @dataclasses.dataclass(frozen=True) + class MyMixin1: + mixin: str + + @dataclasses.dataclass + class MyMixin2: + mixin: str + + @dataclasses.dataclass(frozen=True) + class MyMixin3: + mixin: str = None + + @dataclasses.dataclass + class MyMixin4: + mixin: str = None + + @dataclasses.dataclass(frozen=True, kw_only=True) + class ComplexEntityDescription1A(MyMixin1, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription1A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription1A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(frozen=True, kw_only=True) + class ComplexEntityDescription1B(entity.EntityDescription, MyMixin1): + extra: str = None + + obj = ComplexEntityDescription1B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription1B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(frozen=True) + class ComplexEntityDescription1C(MyMixin1, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription1C( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription1C( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(frozen=True) + class ComplexEntityDescription1D(entity.EntityDescription, MyMixin1): + extra: str = None + + obj = ComplexEntityDescription1D( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription1D( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(kw_only=True) + class ComplexEntityDescription2A(MyMixin2, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription2A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription2A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(kw_only=True) + class ComplexEntityDescription2B(entity.EntityDescription, MyMixin2): + extra: str = None + + obj = ComplexEntityDescription2B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription2B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass + class ComplexEntityDescription2C(MyMixin2, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription2C( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription2C( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass + class ComplexEntityDescription2D(entity.EntityDescription, MyMixin2): + extra: str = None + + obj = ComplexEntityDescription2D( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription2D( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(frozen=True, kw_only=True) + class ComplexEntityDescription3A(MyMixin3, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription3A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription3A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(frozen=True, kw_only=True) + class ComplexEntityDescription3B(entity.EntityDescription, MyMixin3): + extra: str = None + + obj = ComplexEntityDescription3B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription3B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + with pytest.raises(TypeError): + + @dataclasses.dataclass(frozen=True) + class ComplexEntityDescription3C(MyMixin3, entity.EntityDescription): + extra: str = None + + with pytest.raises(TypeError): + + @dataclasses.dataclass(frozen=True) + class ComplexEntityDescription3D(entity.EntityDescription, MyMixin3): + extra: str = None + + @dataclasses.dataclass(kw_only=True) + class ComplexEntityDescription4A(MyMixin4, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription4A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription4A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(kw_only=True) + class ComplexEntityDescription4B(entity.EntityDescription, MyMixin4): + extra: str = None + + obj = ComplexEntityDescription4B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription4B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + with pytest.raises(TypeError): + + @dataclasses.dataclass + class ComplexEntityDescription4C(MyMixin4, entity.EntityDescription): + extra: str = None + + with pytest.raises(TypeError): + + @dataclasses.dataclass + class ComplexEntityDescription4D(entity.EntityDescription, MyMixin4): + extra: str = None + + # Try inheriting with custom init + @dataclasses.dataclass + class CustomInitEntityDescription(entity.EntityDescription): + def __init__(self, extra, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.extra: str = extra + + obj = CustomInitEntityDescription(key="blah", extra="foo", name="name") + assert obj == snapshot + assert obj == CustomInitEntityDescription(key="blah", extra="foo", name="name") + assert repr(obj) == snapshot + + +async def test_update_capabilities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity capabilities are updated automatically.""" + platform = MockEntityPlatform(hass) + + ent = MockEntity(unique_id="qwer") + await platform.async_add_entities([ent]) + + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.device_class is None + assert entry.supported_features == 0 + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = 127 + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == 127 + + ent._values["capability_attributes"] = None + ent._values["device_class"] = None + ent._values["supported_features"] = None + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.original_device_class is None + assert entry.supported_features == 0 + + # Device class can be overridden by user, make sure that does not break the + # automatic updating. + entity_registry.async_update_entity(ent.entity_id, device_class="set_by_user") + await hass.async_block_till_done() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.original_device_class is None + assert entry.supported_features == 0 + + # This will not trigger a state change because the device class is shadowed + # by the entity registry + ent._values["device_class"] = "some_class" + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.original_device_class == "some_class" + assert entry.supported_features == 0 + + +async def test_update_capabilities_no_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity capabilities are updated automatically.""" + platform = MockEntityPlatform(hass) + + ent = MockEntity() + await platform.async_add_entities([ent]) + + assert entity_registry.async_get(ent.entity_id) is None + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["supported_features"] = 127 + ent.async_write_ha_state() + assert entity_registry.async_get(ent.entity_id) is None + + +async def test_update_capabilities_too_often( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity capabilities are updated automatically.""" + capabilities_too_often_warning = "is updating its capabilities too often" + platform = MockEntityPlatform(hass) + + ent = MockEntity(unique_id="qwer") + await platform.async_add_entities([ent]) + + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.device_class is None + assert entry.supported_features == 0 + + for supported_features in range(1, entity.CAPABILITIES_UPDATE_LIMIT + 1): + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + + assert capabilities_too_often_warning not in caplog.text + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + 1 + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + 1 + + assert capabilities_too_often_warning in caplog.text + + +async def test_update_capabilities_too_often_cooldown( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entity capabilities are updated automatically.""" + capabilities_too_often_warning = "is updating its capabilities too often" + platform = MockEntityPlatform(hass) + + ent = MockEntity(unique_id="qwer") + await platform.async_add_entities([ent]) + + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.device_class is None + assert entry.supported_features == 0 + + for supported_features in range(1, entity.CAPABILITIES_UPDATE_LIMIT + 1): + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + + assert capabilities_too_often_warning not in caplog.text + + freezer.tick(timedelta(minutes=60) + timedelta(seconds=1)) + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + 1 + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + 1 + + assert capabilities_too_often_warning not in caplog.text + + +@pytest.mark.parametrize( + ("property", "default_value", "values"), [("attribution", None, ["abcd", "efgh"])] +) +async def test_cached_entity_properties( + hass: HomeAssistant, property: str, default_value: Any, values: Any +) -> None: + """Test entity properties are cached.""" + ent1 = entity.Entity() + ent2 = entity.Entity() + assert getattr(ent1, property) == default_value + assert getattr(ent2, property) == default_value + + # Test set + setattr(ent1, f"_attr_{property}", values[0]) + assert getattr(ent1, property) == values[0] + assert getattr(ent2, property) == default_value + + # Test update + setattr(ent1, f"_attr_{property}", values[1]) + assert getattr(ent1, property) == values[1] + assert getattr(ent2, property) == default_value + + # Test delete + delattr(ent1, f"_attr_{property}") + assert getattr(ent1, property) == default_value + assert getattr(ent2, property) == default_value + + +async def test_cached_entity_property_delete_attr(hass: HomeAssistant) -> None: + """Test deleting an _attr corresponding to a cached property.""" + property = "has_entity_name" + + ent = entity.Entity() + assert not hasattr(ent, f"_attr_{property}") + with pytest.raises(AttributeError): + delattr(ent, f"_attr_{property}") + assert getattr(ent, property) is False + + with pytest.raises(AttributeError): + delattr(ent, f"_attr_{property}") + assert not hasattr(ent, f"_attr_{property}") + assert getattr(ent, property) is False + + setattr(ent, f"_attr_{property}", True) + assert getattr(ent, property) is True + + delattr(ent, f"_attr_{property}") + assert not hasattr(ent, f"_attr_{property}") + assert getattr(ent, property) is False + + +async def test_cached_entity_property_class_attribute(hass: HomeAssistant) -> None: + """Test entity properties on class level work in derived classes.""" + property = "attribution" + values = ["abcd", "efgh"] + + class EntityWithClassAttribute1(entity.Entity): + """A derived class which overrides an _attr_ from a parent.""" + + _attr_attribution = values[0] + + class EntityWithClassAttribute2(entity.Entity, cached_properties={property}): + """A derived class which overrides an _attr_ from a parent. + + This class also redundantly marks the overridden _attr_ as cached. + """ + + _attr_attribution = values[0] + + class EntityWithClassAttribute3(entity.Entity, cached_properties={property}): + """A derived class which overrides an _attr_ from a parent. + + This class overrides the attribute property. + """ + + def __init__(self): + self._attr_attribution = values[0] + + @cached_property + def attribution(self) -> str | None: + """Return the attribution.""" + return self._attr_attribution + + class EntityWithClassAttribute4(entity.Entity, cached_properties={property}): + """A derived class which overrides an _attr_ from a parent. + + This class overrides the attribute property and the _attr_. + """ + + _attr_attribution = values[0] + + @cached_property + def attribution(self) -> str | None: + """Return the attribution.""" + return self._attr_attribution + + classes = ( + EntityWithClassAttribute1, + EntityWithClassAttribute2, + EntityWithClassAttribute3, + EntityWithClassAttribute4, + ) + + entities: list[tuple[entity.Entity, entity.Entity]] = [] + for cls in classes: + entities.append((cls(), cls())) + + for ent in entities: + assert getattr(ent[0], property) == values[0] + assert getattr(ent[1], property) == values[0] + + # Test update + for ent in entities: + setattr(ent[0], f"_attr_{property}", values[1]) + for ent in entities: + assert getattr(ent[0], property) == values[1] + assert getattr(ent[1], property) == values[0] + + +async def test_entity_report_deprecated_supported_features_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reporting deprecated supported feature values only happens once.""" + ent = entity.Entity() + + class MockEntityFeatures(IntFlag): + VALUE1 = 1 + VALUE2 = 2 + + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + in caplog.text + ) + assert "MockEntityFeatures.VALUE2" in caplog.text + + caplog.clear() + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + not in caplog.text + ) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index b5cda6770c50de..60d0774b549a8b 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -35,8 +35,8 @@ 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 @@ def platform_setup( """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 @@ def platform_setup( 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) @@ -215,7 +215,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # Should not trigger attempt 2 async_fire_time_changed(hass, utcnow + timedelta(seconds=29)) @@ -226,7 +226,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=30)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 2 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # This should not trigger attempt 3 async_fire_time_changed(hass, utcnow + timedelta(seconds=59)) @@ -237,7 +237,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 3 - assert "test_domain.mod1" in hass.config.components + assert "mod1.test_domain" in hass.config.components async def test_extract_from_service_fails_if_no_entity_id(hass: HomeAssistant) -> None: @@ -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) @@ -317,15 +317,15 @@ async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert "test_component" in hass.config.components assert "test_component2" in hass.config.components - assert "test_domain.test_component" in hass.config.components + assert "test_component.test_domain" in hass.config.components 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), ) @@ -673,14 +673,14 @@ 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) await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components with patch.object( component._platforms[DOMAIN], "async_shutdown" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 57020268323543..dfaec4577aa888 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -43,7 +43,7 @@ MockEntityPlatform, MockPlatform, async_fire_time_changed, - mock_entity_platform, + mock_platform, mock_registry, ) @@ -195,7 +195,7 @@ def platform_setup( 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) @@ -230,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) @@ -264,11 +264,11 @@ async def setup_platform(*args): 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 - assert "test_domain.test_platform" not in hass.config.components + assert "test_platform.test_domain" not in hass.config.components assert "test_platform is taking longer than 0 seconds" in caplog.text # Cleanup lingering (setup_platform) task after test is done @@ -298,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 = {} @@ -328,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 = {} @@ -355,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 = {} @@ -381,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 = {} @@ -403,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 = {} @@ -431,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 = {} @@ -479,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 = {} @@ -833,7 +833,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 1 assert len(entity_registry.entities) == 1 @@ -856,7 +856,7 @@ async def test_setup_entry_platform_not_ready( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 assert "Platform test not ready yet" in caplog.text @@ -877,7 +877,7 @@ async def test_setup_entry_platform_not_ready_with_message( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -904,7 +904,7 @@ async def test_setup_entry_platform_not_ready_from_exception( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -1660,16 +1660,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): 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"{config_entry.domain}.{platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 assert len(entity_registry.entities) == 1 diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 00ad580693e6eb..245354a09a0121 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -923,7 +923,9 @@ def error_callback(entity_id, old_state, new_state): async def test_track_template_time_change( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test tracking template with time change.""" template_error = Template("{{ utcnow().minute % 2 == 0 }}", hass) @@ -935,17 +937,15 @@ def error_callback(entity_id, old_state, new_state): start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_template(hass, template_error, error_callback) - await hass.async_block_till_done() - assert not calls + freezer.move_to(time_that_will_not_match_right_away) + unsub = async_track_template(hass, template_error, error_callback) + await hass.async_block_till_done() + assert not calls first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0] == (None, None, None) @@ -3312,84 +3312,89 @@ def specific_run_callback( info.async_remove() -async def test_track_template_with_time_that_leaves_scope(hass: HomeAssistant) -> None: +async def test_track_template_with_time_that_leaves_scope( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test tracking template with time.""" now = dt_util.utcnow() test_time = datetime(now.year + 1, 5, 24, 11, 59, 1, 500000, tzinfo=dt_util.UTC) + freezer.move_to(test_time) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - hass.states.async_set("binary_sensor.washing_machine", "on") - specific_runs = [] - template_complex = Template( - """ - {% if states.binary_sensor.washing_machine.state == "on" %} - {{ now() }} - {% else %} - {{ states.binary_sensor.washing_machine.last_updated }} - {% endif %} - """, - hass, - ) - - def specific_run_callback( - event: EventType[EventStateChangedData] | None, - updates: list[TrackTemplateResult], - ) -> None: - specific_runs.append(updates.pop().result) + hass.states.async_set("binary_sensor.washing_machine", "on") + specific_runs = [] + template_complex = Template( + """ + {% if states.binary_sensor.washing_machine.state == "on" %} + {{ now() }} + {% else %} + {{ states.binary_sensor.washing_machine.last_updated }} + {% endif %} + """, + hass, + ) - info = async_track_template_result( - hass, [TrackTemplate(template_complex, None)], specific_run_callback - ) - await hass.async_block_till_done() + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: + specific_runs.append(updates.pop().result) - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + info = async_track_template_result( + hass, [TrackTemplate(template_complex, None)], specific_run_callback + ) + await hass.async_block_till_done() - hass.states.async_set("binary_sensor.washing_machine", "off") - await hass.async_block_till_done() + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": False, - } + hass.states.async_set("binary_sensor.washing_machine", "off") + await hass.async_block_till_done() - hass.states.async_set("binary_sensor.washing_machine", "on") - await hass.async_block_till_done() + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": False, + } - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + hass.states.async_set("binary_sensor.washing_machine", "on") + await hass.async_block_till_done() - # Verify we do not update before the minute rolls over - callback_count_before_time_change = len(specific_runs) - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - async_fire_time_changed(hass, test_time + timedelta(seconds=58)) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + # Verify we do not update before the minute rolls over + callback_count_before_time_change = len(specific_runs) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - # Verify we do update on the next change of minute - async_fire_time_changed(hass, test_time + timedelta(seconds=59)) + new_time = test_time + timedelta(seconds=58) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + 1 + # Verify we do update on the next change of minute + new_time = test_time + timedelta(seconds=59) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change + 1 info.async_remove() async def test_async_track_template_result_multiple_templates_mixing_listeners( - hass: HomeAssistant, + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test tracking multiple templates with mixing listener types.""" @@ -3410,18 +3415,16 @@ def refresh_listener( time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - info = async_track_template_result( - hass, - [ - TrackTemplate(template_1, None), - TrackTemplate(template_2, None), - ], - refresh_listener, - ) + info = async_track_template_result( + hass, + [ + TrackTemplate(template_1, None), + TrackTemplate(template_2, None), + ], + refresh_listener, + ) assert info.listeners == { "all": False, @@ -3450,9 +3453,9 @@ def refresh_listener( refresh_runs = [] next_time = time_that_will_not_match_right_away + timedelta(hours=25) - with patch("homeassistant.util.dt.utcnow", return_value=next_time): - async_fire_time_changed(hass, next_time) - await hass.async_block_till_done() + freezer.move_to(next_time) + async_fire_time_changed(hass, next_time) + await hass.async_block_till_done() assert refresh_runs == [ [ @@ -3787,7 +3790,10 @@ async def test_track_sunset(hass: HomeAssistant) -> None: assert len(offset_runs) == 1 -async def test_async_track_time_change(hass: HomeAssistant) -> None: +async def test_async_track_time_change( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test tracking time change.""" none_runs = [] wildcard_runs = [] @@ -3798,21 +3804,19 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) - unsub_utc = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] - ) - unsub_wildcard = async_track_time_change( - hass, - callback(lambda x: wildcard_runs.append(x)), - second="*", - minute="*", - hour="*", - ) + unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) + unsub_utc = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] + ) + unsub_wildcard = async_track_time_change( + hass, + callback(lambda x: wildcard_runs.append(x)), + second="*", + minute="*", + hour="*", + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3851,7 +3855,10 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: assert len(none_runs) == 3 -async def test_periodic_task_minute(hass: HomeAssistant) -> None: +async def test_periodic_task_minute( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per minute.""" specific_runs = [] @@ -3860,13 +3867,11 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 - ) + unsub = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3895,7 +3900,10 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: assert len(specific_runs) == 2 -async def test_periodic_task_hour(hass: HomeAssistant) -> None: +async def test_periodic_task_hour( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per hour.""" specific_runs = [] @@ -3904,17 +3912,15 @@ async def test_periodic_task_hour(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3973,71 +3979,77 @@ async def test_periodic_task_wrong_input(hass: HomeAssistant) -> None: assert len(specific_runs) == 0 -async def test_periodic_task_clock_rollback(hass: HomeAssistant) -> None: +async def test_periodic_task_clock_rollback( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic tasks with the time rolling backwards.""" specific_runs = [] now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) - - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, ) + + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 unsub() - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 -async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: +async def test_periodic_task_duplicate_time( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks not triggering on duplicate time.""" specific_runs = [] @@ -4046,17 +4058,15 @@ async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) diff --git a/tests/helpers/test_group.py b/tests/helpers/test_group.py new file mode 100644 index 00000000000000..b1300009607c68 --- /dev/null +++ b/tests/helpers/test_group.py @@ -0,0 +1,107 @@ +"""Test the group helper.""" + + +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import group + + +async def test_expand_entity_ids(hass: HomeAssistant) -> None: + """Test expand_entity_ids method.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set( + "group.init_group", STATE_ON, {ATTR_ENTITY_ID: ["light.bowl", "light.ceiling"]} + ) + state = hass.states.get("group.init_group") + assert state is not None + assert state.attributes[ATTR_ENTITY_ID] == ["light.bowl", "light.ceiling"] + + assert sorted(group.expand_entity_ids(hass, ["group.init_group"])) == [ + "light.bowl", + "light.ceiling", + ] + assert sorted(group.expand_entity_ids(hass, ["group.INIT_group"])) == [ + "light.bowl", + "light.ceiling", + ] + + +async def test_expand_entity_ids_does_not_return_duplicates( + hass: HomeAssistant, +) -> None: + """Test that expand_entity_ids does not return duplicates.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set( + "group.init_group", STATE_ON, {ATTR_ENTITY_ID: ["light.bowl", "light.ceiling"]} + ) + + assert sorted( + group.expand_entity_ids(hass, ["group.init_group", "light.Ceiling"]) + ) == ["light.bowl", "light.ceiling"] + + assert sorted( + group.expand_entity_ids(hass, ["light.bowl", "group.init_group"]) + ) == ["light.bowl", "light.ceiling"] + + +async def test_expand_entity_ids_recursive(hass: HomeAssistant) -> None: + """Test expand_entity_ids method with a group that contains itself.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set( + "group.init_group", STATE_ON, {ATTR_ENTITY_ID: ["light.bowl", "light.ceiling"]} + ) + + hass.states.async_set( + "group.rec_group", + STATE_ON, + {ATTR_ENTITY_ID: ["group.init_group", "light.ceiling"]}, + ) + + assert sorted(group.expand_entity_ids(hass, ["group.rec_group"])) == [ + "light.bowl", + "light.ceiling", + ] + + +async def test_expand_entity_ids_ignores_non_strings(hass: HomeAssistant) -> None: + """Test that non string elements in lists are ignored.""" + assert group.expand_entity_ids(hass, [5, True]) == [] + + +async def test_get_entity_ids(hass: HomeAssistant) -> None: + """Test get_entity_ids method.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set( + "group.init_group", STATE_ON, {ATTR_ENTITY_ID: ["light.bowl", "light.ceiling"]} + ) + + assert sorted(group.get_entity_ids(hass, "group.init_group")) == [ + "light.bowl", + "light.ceiling", + ] + + +async def test_get_entity_ids_with_domain_filter(hass: HomeAssistant) -> None: + """Test if get_entity_ids works with a domain_filter.""" + hass.states.async_set("switch.AC", STATE_OFF) + hass.states.async_set( + "group.mixed_group", STATE_ON, {ATTR_ENTITY_ID: ["light.bowl", "switch.ac"]} + ) + + assert group.get_entity_ids(hass, "group.mixed_group", domain_filter="switch") == [ + "switch.ac" + ] + + +async def test_get_entity_ids_with_non_existing_group_name(hass: HomeAssistant) -> None: + """Test get_entity_ids with a non existing group.""" + assert group.get_entity_ids(hass, "non_existing") == [] + + +async def test_get_entity_ids_with_non_group_state(hass: HomeAssistant) -> None: + """Test get_entity_ids with a non group state.""" + assert group.get_entity_ids(hass, "switch.AC") == [] diff --git a/tests/helpers/test_init.py b/tests/helpers/test_init.py index c567c6bc7bc9be..39b387000ca3ce 100644 --- a/tests/helpers/test_init.py +++ b/tests/helpers/test_init.py @@ -2,10 +2,12 @@ from collections import OrderedDict +import pytest + from homeassistant import helpers -def test_extract_domain_configs() -> None: +def test_extract_domain_configs(caplog: pytest.LogCaptureFixture) -> None: """Test the extraction of domain configuration.""" config = { "zone": None, @@ -19,8 +21,13 @@ def test_extract_domain_configs() -> None: helpers.extract_domain_configs(config, "zone") ) + assert ( + "helpers.extract_domain_configs is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.extract_domain_configs instead" in caplog.text + ) + -def test_config_per_platform() -> None: +def test_config_per_platform(caplog: pytest.LogCaptureFixture) -> None: """Test config per platform method.""" config = OrderedDict( [ @@ -36,3 +43,8 @@ def test_config_per_platform() -> None: (None, 1), ("hello 2", config["zone Hallo"][1]), ] == list(helpers.config_per_platform(config, "zone")) + + assert ( + "helpers.config_per_platform is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.config_per_platform instead" in caplog.text + ) diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 8d4733380583a4..0486211417ca5b 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -192,7 +192,7 @@ async def test_cant_turn_on_lock(hass: HomeAssistant) -> None: ) assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS def test_async_register(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index ad3b7ccb243c8f..4425ce00ce1004 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -3,10 +3,12 @@ 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 @@ MockModule, MockPlatform, get_fixture_path, - mock_entity_platform, mock_integration, + mock_platform, ) _LOGGER = logging.getLogger(__name__) @@ -42,8 +44,8 @@ async def setup_platform(*args): 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) @@ -51,7 +53,7 @@ async def setup_platform(*args): await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) @@ -82,8 +84,8 @@ async def setup_platform(*args): 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) @@ -91,7 +93,7 @@ async def setup_platform(*args): await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -123,8 +125,8 @@ async def setup_platform(*args): 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) @@ -132,14 +134,16 @@ async def setup_platform(*args): await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) 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 async_reset_platform(*args): 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) @@ -182,7 +186,7 @@ async def async_reset_platform(*args): await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -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"}]} + + +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. - assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]} + 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 fa0a14b8fbbddd..d69996e5d29562 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -31,8 +31,8 @@ MockModule, MockPlatform, async_fire_time_changed, - mock_entity_platform, mock_integration, + mock_platform, ) _LOGGER = logging.getLogger(__name__) @@ -499,8 +499,8 @@ async def async_setup_platform( 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) @@ -508,7 +508,7 @@ async def async_setup_platform( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index b069f0cb8f5615..58f6a261aefd37 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.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" @@ -232,7 +226,7 @@ class TestFlow(MockSchemaConfigFlowHandler, domain="test"): 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", @@ -521,7 +515,7 @@ class TestFlow(MockSchemaConfigFlowHandler, domain="test"): 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", @@ -634,7 +628,7 @@ class TestFlow(MockSchemaConfigFlowHandler, domain="test"): 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", @@ -700,7 +694,7 @@ class TestFlow(MockSchemaConfigFlowHandler, domain="test"): 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 7e655a69c0aa51..1ea602f7cdaa60 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -9,6 +9,7 @@ from unittest import mock from unittest.mock import AsyncMock, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -385,7 +386,10 @@ def mock_service(call: ServiceCall) -> ServiceResponse: "target": {}, }, "running_script": False, - } + }, + "variables": { + "my_response": {"data": "value-12345"}, + }, } ], "1": [ @@ -398,10 +402,7 @@ def mock_service(call: ServiceCall) -> ServiceResponse: "target": {}, }, "running_script": False, - }, - "variables": { - "my_response": {"data": "value-12345"}, - }, + } } ], } @@ -1162,13 +1163,13 @@ async def test_wait_template_not_schedule(hass: HomeAssistant) -> None: assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"result": {"wait": {"completed": True, "remaining": None}}}], - "2": [ + "1": [ { - "result": {"event": "test_event", "event_data": {}}, + "result": {"wait": {"completed": True, "remaining": None}}, "variables": {"wait": {"completed": True, "remaining": None}}, } ], + "2": [{"result": {"event": "test_event", "event_data": {}}}], } ) @@ -1229,13 +1230,13 @@ async def test_wait_timeout( else: variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} expected_trace = { - "0": [{"result": variable_wait}], - "1": [ + "0": [ { - "result": {"event": "test_event", "event_data": {}}, + "result": variable_wait, "variables": variable_wait, } ], + "1": [{"result": {"event": "test_event", "event_data": {}}}], } assert_action_trace(expected_trace) @@ -1290,19 +1291,14 @@ async def test_wait_continue_on_timeout( else: variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} expected_trace = { - "0": [{"result": variable_wait}], + "0": [{"result": variable_wait, "variables": variable_wait}], } if continue_on_timeout is False: expected_trace["0"][0]["result"]["timeout"] = True expected_trace["0"][0]["error_type"] = asyncio.TimeoutError expected_script_execution = "aborted" else: - expected_trace["1"] = [ - { - "result": {"event": "test_event", "event_data": {}}, - "variables": variable_wait, - } - ] + expected_trace["1"] = [{"result": {"event": "test_event", "event_data": {}}}] expected_script_execution = "finished" assert_action_trace(expected_trace, expected_script_execution) @@ -1346,13 +1342,13 @@ async def test_wait_template_with_utcnow(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running match_time = start_time.replace(hour=12) - with patch("homeassistant.util.dt.utcnow", return_value=match_time): + with freeze_time(match_time): async_fire_time_changed(hass, match_time) except (AssertionError, asyncio.TimeoutError): await script_obj.async_stop() @@ -1378,15 +1374,13 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running second_non_matching_time = start_time.replace(hour=4) - with patch( - "homeassistant.util.dt.utcnow", return_value=second_non_matching_time - ): + with freeze_time(second_non_matching_time): async_fire_time_changed(hass, second_non_matching_time) async with asyncio.timeout(0.1): @@ -3270,12 +3264,12 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - "description": "state of switch.trigger", }, } - } + }, + "variables": {"wait": {"remaining": None}}, } ], "0/parallel/1/sequence/0": [ { - "variables": {}, "result": { "event": "test_event", "event_data": {"hello": "from action 2", "what": "world"}, @@ -3284,7 +3278,6 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - ], "0/parallel/0/sequence/1": [ { - "variables": {"wait": {"remaining": None}}, "result": { "event": "test_event", "event_data": {"hello": "from action 1", "what": "world"}, @@ -4463,7 +4456,7 @@ async def test_set_variable( assert f"Executing step {alias}" in caplog.text expected_trace = { - "0": [{}], + "0": [{"variables": {"variable": "value"}}], "1": [ { "result": { @@ -4475,7 +4468,6 @@ async def test_set_variable( }, "running_script": False, }, - "variables": {"variable": "value"}, } ], } @@ -4505,7 +4497,7 @@ async def test_set_redefines_variable( assert mock_calls[1].data["value"] == 2 expected_trace = { - "0": [{}], + "0": [{"variables": {"variable": "1"}}], "1": [ { "result": { @@ -4516,11 +4508,10 @@ async def test_set_redefines_variable( "target": {}, }, "running_script": False, - }, - "variables": {"variable": "1"}, + } } ], - "2": [{}], + "2": [{"variables": {"variable": 2}}], "3": [ { "result": { @@ -4531,8 +4522,7 @@ async def test_set_redefines_variable( "target": {}, }, "running_script": False, - }, - "variables": {"variable": 2}, + } } ], } diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 1e449fd103a4e8..e925b425f96475 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -396,6 +396,7 @@ def test_assist_pipeline_selector_schema( ({"min": 10, "max": 1000, "mode": "slider", "step": 0.5}, (), ()), ({"mode": "box"}, (10,), ()), ({"mode": "box", "step": "any"}, (), ()), + ({"mode": "slider", "min": 0, "max": 1, "step": "any"}, (), ()), ), ) def test_number_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -408,12 +409,6 @@ def test_number_selector_schema(schema, valid_selections, invalid_selections) -> ( {}, # Must have mandatory fields {"mode": "slider"}, # Must have min+max in slider mode - { - "mode": "slider", - "min": 0, - "max": 1, - "step": "any", # Can't combine slider with step any - }, ), ) def test_number_selector_schema_error(schema) -> None: @@ -602,6 +597,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 +907,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( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 04324cdbfa33e6..628ead473d7545 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -802,7 +802,7 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, test_service_mock, ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A], @@ -821,7 +821,7 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - with pytest.raises(exceptions.HomeAssistantError): await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, test_service_mock, ServiceCall( "test_domain", "test_service", {"entity_id": "light.living_room"} @@ -838,7 +838,7 @@ async def test_call_with_both_required_features( test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, test_service_mock, ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A | SUPPORT_B], @@ -857,7 +857,7 @@ async def test_call_with_one_of_required_features( test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, test_service_mock, ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A, SUPPORT_C], @@ -878,7 +878,7 @@ async def test_call_with_sync_func(hass: HomeAssistant, mock_entities) -> None: test_service_mock = Mock(return_value=None) await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, test_service_mock, ServiceCall("test_domain", "test_service", {"entity_id": "light.kitchen"}), ) @@ -890,7 +890,7 @@ async def test_call_with_sync_attr(hass: HomeAssistant, mock_entities) -> None: mock_method = mock_entities["light.kitchen"].sync_method = Mock(return_value=None) await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, "sync_method", ServiceCall( "test_domain", @@ -908,7 +908,7 @@ async def test_call_context_user_not_exist(hass: HomeAssistant) -> None: with pytest.raises(exceptions.UnknownUser) as err: await service.entity_service_call( hass, - [], + {}, Mock(), ServiceCall( "test_domain", @@ -935,7 +935,7 @@ async def test_call_context_target_all( ): await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall( "test_domain", @@ -963,7 +963,7 @@ async def test_call_context_target_specific( ): await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall( "test_domain", @@ -987,7 +987,7 @@ async def test_call_context_target_specific_no_auth( ): await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall( "test_domain", @@ -1007,7 +1007,7 @@ async def test_call_no_context_target_all( """Check we target all if no user context given.""" await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall( "test_domain", "test_service", data={"entity_id": ENTITY_MATCH_ALL} @@ -1026,7 +1026,7 @@ async def test_call_no_context_target_specific( """Check we can target specified entities.""" await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall( "test_domain", @@ -1048,7 +1048,7 @@ async def test_call_with_match_all( """Check we only target allowed entities if targeting all.""" await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall("test_domain", "test_service", {"entity_id": "all"}), ) @@ -1065,7 +1065,7 @@ async def test_call_with_omit_entity_id( """Check service call if we do not pass an entity ID.""" await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall("test_domain", "test_service"), ) diff --git a/tests/helpers/test_significant_change.py b/tests/helpers/test_significant_change.py index c0d9f1b3a4aa54..6444781aa85993 100644 --- a/tests/helpers/test_significant_change.py +++ b/tests/helpers/test_significant_change.py @@ -72,3 +72,14 @@ def extra_significant_check( State(ent_id, "200", attrs), extra_arg=1 ) assert checker.async_is_significant_change(State(ent_id, "200", attrs), extra_arg=2) + + +async def test_check_valid_float(hass: HomeAssistant) -> None: + """Test extra significant checker works.""" + assert significant_change.check_valid_float("1") + assert significant_change.check_valid_float("1.0") + assert significant_change.check_valid_float(1) + assert significant_change.check_valid_float(1.0) + assert not significant_change.check_valid_float("") + assert not significant_change.check_valid_float("invalid") + assert not significant_change.check_valid_float("1.1.1") diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index e030958ab8240e..b6dc1616a481b4 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -1,8 +1,8 @@ """The tests for the Sun helpers.""" from datetime import datetime, timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET @@ -77,7 +77,7 @@ def test_next_events(hass: HomeAssistant) -> None: break mod += 1 - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert next_dawn == sun.get_astral_event_next(hass, "dawn") assert next_dusk == sun.get_astral_event_next(hass, "dusk") assert next_midnight == sun.get_astral_event_next(hass, "midnight") @@ -132,7 +132,7 @@ def test_date_events_default_date(hass: HomeAssistant) -> None: sunrise = astral.sun.sunrise(location.observer, date=utc_today) sunset = astral.sun.sunset(location.observer, date=utc_today) - with patch("homeassistant.util.dt.now", return_value=utc_now): + with freeze_time(utc_now): assert dawn == sun.get_astral_event_date(hass, "dawn", utc_today) assert dusk == sun.get_astral_event_date(hass, "dusk", utc_today) assert midnight == sun.get_astral_event_date(hass, "midnight", utc_today) @@ -171,11 +171,11 @@ def test_date_events_accepts_datetime(hass: HomeAssistant) -> None: def test_is_up(hass: HomeAssistant) -> None: """Test retrieving next sun events.""" utc_now = datetime(2016, 11, 1, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert not sun.is_up(hass) utc_now = datetime(2016, 11, 1, 18, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert sun.is_up(hass) diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index ebb0cc35c20bfa..5c3697ad936483 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 79358ec588de97..b70c9479abbd6c 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -22,13 +22,13 @@ ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_UNAVAILABLE, - VOLUME_LITERS, UnitOfLength, UnitOfMass, UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfVolume, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError @@ -60,7 +60,7 @@ def _set_up_units(hass: HomeAssistant) -> None: mass=UnitOfMass.GRAMS, pressure=UnitOfPressure.PA, temperature=UnitOfTemperature.CELSIUS, - volume=VOLUME_LITERS, + volume=UnitOfVolume.LITERS, wind_speed=UnitOfSpeed.KILOMETERS_PER_HOUR, ) @@ -1233,6 +1233,22 @@ def test_to_json(hass: HomeAssistant) -> None: with pytest.raises(TemplateError): template.Template("{{ {'Foo': now()} | to_json }}", hass).async_render() + # Test special case where substring class cannot be rendered + # See: https://github.com/ijl/orjson/issues/445 + class MyStr(str): + pass + + expected_result = '{"mykey1":11.0,"mykey2":"myvalue2","mykey3":["opt3b","opt3a"]}' + test_dict = { + MyStr("mykey2"): "myvalue2", + MyStr("mykey1"): 11.0, + MyStr("mykey3"): ["opt3b", "opt3a"], + } + actual_result = template.Template( + "{{ test_dict | to_json(sort_keys=True) }}", hass + ).async_render(parse_result=False, variables={"test_dict": test_dict}) + assert actual_result == expected_result + def test_to_json_ensure_ascii(hass: HomeAssistant) -> None: """Test the object to JSON string filter.""" @@ -1302,6 +1318,88 @@ def test_average(hass: HomeAssistant) -> None: template.Template("{{ average([]) }}", hass).async_render() +def test_median(hass: HomeAssistant) -> None: + """Test the median filter.""" + assert template.Template("{{ [1, 3, 2] | median }}", hass).async_render() == 2 + assert template.Template("{{ median([1, 3, 2, 4]) }}", hass).async_render() == 2.5 + assert template.Template("{{ median(1, 3, 2) }}", hass).async_render() == 2 + assert template.Template("{{ median('cdeba') }}", hass).async_render() == "c" + + # Testing of default values + assert template.Template("{{ median([1, 2, 3], -1) }}", hass).async_render() == 2 + assert template.Template("{{ median([], -1) }}", hass).async_render() == -1 + assert template.Template("{{ median([], default=-1) }}", hass).async_render() == -1 + assert template.Template("{{ median('abcd', -1) }}", hass).async_render() == -1 + assert ( + template.Template("{{ median([], 5, default=-1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ median(1, 'a', 3, default=-1) }}", hass).async_render() + == -1 + ) + + with pytest.raises(TemplateError): + template.Template("{{ 1 | median }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median([]) }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median('abcd') }}", hass).async_render() + + +def test_statistical_mode(hass: HomeAssistant) -> None: + """Test the mode filter.""" + assert ( + template.Template("{{ [1, 2, 2, 3] | statistical_mode }}", hass).async_render() + == 2 + ) + assert ( + template.Template("{{ statistical_mode([1, 2, 3]) }}", hass).async_render() == 1 + ) + assert ( + template.Template( + "{{ statistical_mode('hello', 'bye', 'hello') }}", hass + ).async_render() + == "hello" + ) + assert ( + template.Template("{{ statistical_mode('banana') }}", hass).async_render() + == "a" + ) + + # Testing of default values + assert ( + template.Template("{{ statistical_mode([1, 2, 3], -1) }}", hass).async_render() + == 1 + ) + assert ( + template.Template("{{ statistical_mode([], -1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ statistical_mode([], default=-1) }}", hass).async_render() + == -1 + ) + assert ( + template.Template( + "{{ statistical_mode([], 5, default=-1) }}", hass + ).async_render() + == -1 + ) + + with pytest.raises(TemplateError): + template.Template("{{ 1 | statistical_mode }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ statistical_mode() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ statistical_mode([]) }}", hass).async_render() + + def test_min(hass: HomeAssistant) -> None: """Test the min filter.""" assert template.Template("{{ [1, 2, 3] | min }}", hass).async_render() == 1 @@ -1853,7 +1951,7 @@ def test_has_value(hass: HomeAssistant) -> None: def test_now(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" now = dt_util.now() - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): info = template.Template("{{ now().isoformat() }}", hass).async_render_to_info() assert now.isoformat() == info.result() @@ -1867,7 +1965,7 @@ def test_now(mock_is_safe, hass: HomeAssistant) -> None: def test_utcnow(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + with freeze_time(utcnow): info = template.Template( "{{ utcnow().isoformat() }}", hass ).async_render_to_info() @@ -1954,7 +2052,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: relative_time_template = ( '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' ) - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( relative_time_template, hass, @@ -2026,7 +2124,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( "{{timedelta(seconds=120)}}", hass, diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 6f5b425321859b..350e706ca1db9d 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -56,14 +56,14 @@ async def test_component_translation_path( ) assert path.normpath( - translation.component_translation_path("switch.test", "en", int_test) + translation.component_translation_path("test.switch", "en", int_test) ) == path.normpath( hass.config.path("custom_components", "test", "translations", "switch.en.json") ) assert path.normpath( translation.component_translation_path( - "switch.test_embedded", "en", int_test_embedded + "test_embedded.switch", "en", int_test_embedded ) ) == path.normpath( hass.config.path( @@ -98,6 +98,77 @@ def test_load_translations_files(hass: HomeAssistant) -> None: } +@pytest.mark.parametrize( + ("language", "expected_translation", "expected_errors"), + ( + ( + "en", + { + "component.test.entity.switch.other1.name": "Other 1", + "component.test.entity.switch.other2.name": "Other 2", + "component.test.entity.switch.other3.name": "Other 3", + "component.test.entity.switch.other4.name": "Other 4", + "component.test.entity.switch.outlet.name": "Outlet {placeholder}", + }, + [], + ), + ( + "es", + { + "component.test.entity.switch.other1.name": "Otra 1", + "component.test.entity.switch.other2.name": "Otra 2", + "component.test.entity.switch.other3.name": "Otra 3", + "component.test.entity.switch.other4.name": "Otra 4", + "component.test.entity.switch.outlet.name": "Enchufe {placeholder}", + }, + [], + ), + ( + "de", + { + # Correct + "component.test.entity.switch.other1.name": "Anderes 1", + # Translation has placeholder missing in English + "component.test.entity.switch.other2.name": "Other 2", + # Correct (empty translation) + "component.test.entity.switch.other3.name": "", + # Translation missing + "component.test.entity.switch.other4.name": "Other 4", + # Mismatch in placeholders + "component.test.entity.switch.outlet.name": "Outlet {placeholder}", + }, + [ + "component.test.entity.switch.other2.name", + "component.test.entity.switch.outlet.name", + ], + ), + ), +) +async def test_load_translations_files_invalid_localized_placeholders( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, + language: str, + expected_translation: dict, + expected_errors: bool, +) -> None: + """Test the load translation files with invalid localized placeholders.""" + caplog.clear() + translations = await translation.async_get_translations( + hass, language, "entity", ["test"] + ) + assert translations == expected_translation + + assert ("Validation of translation placeholders" in caplog.text) == ( + len(expected_errors) > 0 + ) + for expected_error in expected_errors: + assert ( + f"Validation of translation placeholders for localized ({language}) string {expected_error} failed" + in caplog.text + ) + + async def test_get_translations( hass: HomeAssistant, mock_config_flows, enable_custom_integrations: None ) -> None: @@ -255,7 +326,7 @@ async def test_translation_merging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we merge translations of two integrations.""" - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") hass.config.components.add("sensor") orig_load_translations = translation.load_translations_files @@ -263,7 +334,7 @@ async def test_translation_merging( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -276,13 +347,13 @@ def mock_load_translations_files(files): assert "component.sensor.state.moon__phase.first_quarter" in translations - hass.config.components.add("sensor.season") + hass.config.components.add("season.sensor") # Patch in some bad translation data def mock_load_bad_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.season"] = {"state": "bad data"} + result["season.sensor"] = {"state": "bad data"} return result with patch( @@ -308,7 +379,7 @@ async def test_translation_merging_loaded_apart( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -323,7 +394,7 @@ def mock_load_translations_files(files): assert "component.sensor.state.moon__phase.first_quarter" not in translations - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") with patch( "homeassistant.helpers.translation.load_translations_files", diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 06dff1e08699d6..425ad561f509e6 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -78,7 +78,10 @@ def test_config_platform_valid( ( BASE_CONFIG + "light:\n platform: beer", {"homeassistant", "light"}, - "Platform error light.beer - Integration 'beer' not found.", + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), ), ], ) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr new file mode 100644 index 00000000000000..76d3f0c46668e1 --- /dev/null +++ b/tests/snapshots/test_config.ambr @@ -0,0 +1,453 @@ +# serializer version: 1 +# name: test_component_config_validation_error[basic] + list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 62", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), + 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' from integration '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' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration '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 domain 'iot_domain ' at configuration.yaml, line 11", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 12", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), + 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' from integration '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' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration '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' from integration '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' from integration 'non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration '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' from integration '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' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration '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': "Setup of package 'pack_iot_domain_space' at configuration.yaml, line 72 failed: Invalid domain 'iot_domain '", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at configuration.yaml, line 74 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at configuration.yaml, line 76 failed: Invalid domain '5'", + }), + 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' from integration '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' from integration 'non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration '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': "Setup of package 'pack_5' at integrations/pack_5.yaml, line 1 failed: Invalid domain '5'", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at integrations/pack_empty.yaml, line 1 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at integrations/pack_iot_domain_space.yaml, line 1 failed: Invalid domain 'iot_domain '", + }), + 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' from integration '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' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration '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 domain 'iot_domain ' at configuration.yaml, line 61", + "Invalid domain '' at configuration.yaml, line 62", + "Invalid domain '5' at configuration.yaml, line 1", + "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' from integration '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' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + ''' + Invalid config for 'iot_domain' from integration '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' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration '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/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr new file mode 100644 index 00000000000000..bfb583ba8db3f8 --- /dev/null +++ b/tests/snapshots/test_config_entries.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_as_dict + dict({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'test', + 'entry_id': 'mock-entry', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }) +# --- diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 555bcbdf6b20f1..4c350168d4e90c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -23,8 +23,8 @@ 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) @@ -40,7 +40,7 @@ async def apply_stop_hass(stop_hass: None) -> None: """Make sure all hass are stopped.""" -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def mock_http_start_stop() -> Generator[None, None, None]: """Mock HTTP start and stop.""" with patch( @@ -327,7 +327,7 @@ async def async_setup(hass, config): 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 @@ -911,6 +913,7 @@ class MockConfigFlow: """Mock the MQTT config flow.""" VERSION = 1 + MINOR_VERSION = 1 entry = MockConfigEntry(domain="mqtt", data={"broker": "test-broker"}) entry.add_to_hass(hass) @@ -1011,7 +1014,10 @@ async def mock_async_get_integrations( 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 d5181bbe115197..0f6b36d90b5e09 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 @@ __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.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" + + 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_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_config( + await config_util.async_process_component_and_handle_errors( 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")) - ) - ), - ), + 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( @@ -1369,6 +1980,30 @@ async def test_core_config_schema_no_country(hass: HomeAssistant) -> None: assert issue +@pytest.mark.parametrize( + ("config", "expected_issue"), + [ + ({}, None), + ({"legacy_templates": True}, "legacy_templates_true"), + ({"legacy_templates": False}, "legacy_templates_false"), + ], +) +async def test_core_config_schema_legacy_template( + hass: HomeAssistant, config: dict[str, Any], expected_issue: str | None +) -> None: + """Test legacy_template core config schema.""" + await config_util.async_process_ha_core_config(hass, config) + + issue_registry = ir.async_get(hass) + for issue_id in {"legacy_templates_true", "legacy_templates_false"}: + issue = issue_registry.async_get_issue("homeassistant", issue_id) + assert issue if issue_id == expected_issue else not issue + + await config_util.async_process_ha_core_config(hass, {}) + for issue_id in {"legacy_templates_true", "legacy_templates_false"}: + assert not issue_registry.async_get_issue("homeassistant", issue_id) + + async def test_core_store_no_country( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -1399,3 +2034,233 @@ 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, cv.domain_key(domain_with_label) + ) + 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, cv.domain_key(domain_with_label) + ) + 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 + + +def test_extract_domain_configs() -> None: + """Test the extraction of domain configuration.""" + config = { + "zone": None, + "zoner": None, + "zone ": None, + "zone Hallo": None, + "zone 100": None, + } + + assert {"zone", "zone Hallo", "zone 100"} == set( + config_util.extract_domain_configs(config, "zone") + ) + + +def test_config_per_platform() -> None: + """Test config per platform method.""" + config = OrderedDict( + [ + ("zone", {"platform": "hello"}), + ("zoner", None), + ("zone Hallo", [1, {"platform": "hello 2"}]), + ("zone 100", None), + ] + ) + + assert [ + ("hello", config["zone"]), + (None, 1), + ("hello 2", config["zone Hallo"][1]), + ] == list(config_util.config_per_platform(config, "zone")) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index a3c052971e3d83..e9989b6839ebf3 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries, data_entry_flow, loader from homeassistant.components import dhcp @@ -40,8 +41,8 @@ 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 +93,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 +122,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", {}) @@ -133,11 +134,15 @@ async def test_call_setup_entry_without_reload_support(hass: HomeAssistant) -> N assert not entry.supports_unload -async def test_call_async_migrate_entry(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test we call .async_migrate_entry when version mismatch.""" entry = MockConfigEntry(domain="comp") assert not entry.supports_unload - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) mock_migrate_entry = AsyncMock(return_value=True) @@ -151,7 +156,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", {}) @@ -163,10 +168,14 @@ async def test_call_async_migrate_entry(hass: HomeAssistant) -> None: assert entry.supports_unload -async def test_call_async_migrate_entry_failure_false(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry_failure_false( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test migration fails if returns false.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -181,7 +190,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 @@ -191,10 +200,14 @@ async def test_call_async_migrate_entry_failure_false(hass: HomeAssistant) -> No assert not entry.supports_unload -async def test_call_async_migrate_entry_failure_exception(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry_failure_exception( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test migration fails if exception raised.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -209,7 +222,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 @@ -219,10 +232,14 @@ async def test_call_async_migrate_entry_failure_exception(hass: HomeAssistant) - assert not entry.supports_unload -async def test_call_async_migrate_entry_failure_not_bool(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry_failure_not_bool( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test migration fails if boolean not returned.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -237,7 +254,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 @@ -247,19 +264,21 @@ async def test_call_async_migrate_entry_failure_not_bool(hass: HomeAssistant) -> assert not entry.supports_unload +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (2, 2)]) async def test_call_async_migrate_entry_failure_not_supported( - hass: HomeAssistant, + hass: HomeAssistant, major_version: int, minor_version: int ) -> None: """Test migration fails if async_migrate_entry not implemented.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload 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 @@ -268,6 +287,29 @@ async def test_call_async_migrate_entry_failure_not_supported( assert not entry.supports_unload +@pytest.mark.parametrize(("major_version", "minor_version"), [(1, 2)]) +async def test_call_async_migrate_entry_not_supported_minor_version( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: + """Test migration without async_migrate_entry and minor version changed.""" + entry = MockConfigEntry(domain="comp") + entry.version = major_version + entry.minor_version = minor_version + entry.add_to_hass(hass) + assert not entry.supports_unload + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + + result = await async_setup_component(hass, "comp", {}) + assert result + assert len(mock_setup_entry.mock_calls) == 1 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert not entry.supports_unload + + async def test_remove_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -311,10 +353,10 @@ async def mock_setup_entry_platform( 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") @@ -371,7 +413,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) @@ -510,7 +552,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.""" @@ -610,7 +652,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.""" @@ -667,14 +709,43 @@ async def async_step_user(self, user_input=None): for orig, loaded in zip( hass.config_entries.async_entries(), manager.async_entries() ): - assert orig.version == loaded.version - assert orig.domain == loaded.domain - assert orig.title == loaded.title - assert orig.data == loaded.data - assert orig.source == loaded.source - assert orig.unique_id == loaded.unique_id - assert orig.pref_disable_new_entities == loaded.pref_disable_new_entities - assert orig.pref_disable_polling == loaded.pref_disable_polling + assert orig.as_dict() == loaded.as_dict() + + +async def test_as_dict(snapshot: SnapshotAssertion) -> None: + """Test ConfigEntry.as_dict.""" + + # Ensure as_dict is not overridden + assert MockConfigEntry.as_dict is config_entries.ConfigEntry.as_dict + + excluded_from_dict = { + "supports_unload", + "supports_remove_device", + "state", + "_setup_lock", + "update_listeners", + "reason", + "_async_cancel_retry_setup", + "_on_unload", + "reload_lock", + "_reauth_lock", + "_tasks", + "_background_tasks", + "_integration_for_domain", + "_tries", + "_setup_again_job", + } + + entry = MockConfigEntry(entry_id="mock-entry") + + # Make sure the expected keys are present + dict_repr = entry.as_dict() + for key in config_entries.ConfigEntry.__slots__: + assert key in dict_repr or key in excluded_from_dict + assert not (key in dict_repr and key in excluded_from_dict) + + # Make sure the dict representation is as expected + assert dict_repr == snapshot async def test_forward_entry_sets_up_component(hass: HomeAssistant) -> None: @@ -721,7 +792,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): @@ -775,7 +846,7 @@ async def async_step_discovery_confirm(self, discovery_info): 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): @@ -842,7 +913,7 @@ async def async_step_reauth_confirm(self, user_input): 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.""" @@ -940,7 +1011,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) @@ -978,7 +1049,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) @@ -996,7 +1067,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) @@ -1018,7 +1089,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() @@ -1043,7 +1114,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) @@ -1081,7 +1152,7 @@ async def mock_async_setup(hass, config): "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.""" @@ -1114,7 +1185,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) @@ -1152,7 +1223,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) @@ -1186,7 +1257,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.""" @@ -1218,7 +1289,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 @@ -1350,7 +1421,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 @@ -1389,7 +1460,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 @@ -1458,7 +1529,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( @@ -1495,7 +1566,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( @@ -1536,7 +1607,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) @@ -1582,7 +1653,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, @@ -1634,7 +1705,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( @@ -1676,7 +1747,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.""" @@ -1724,7 +1795,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.""" @@ -1772,7 +1843,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.""" @@ -1811,7 +1882,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.""" @@ -1857,7 +1928,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): @@ -1923,7 +1994,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.""" @@ -1991,7 +2062,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.""" @@ -2025,7 +2096,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.""" @@ -2061,7 +2132,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.""" @@ -2100,7 +2171,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.""" @@ -2157,7 +2228,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.""" @@ -2204,7 +2275,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.""" @@ -2245,7 +2316,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.""" @@ -2282,7 +2353,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.""" @@ -2323,7 +2394,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.""" @@ -2351,7 +2422,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.""" @@ -2398,7 +2469,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.""" @@ -2448,7 +2519,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.""" @@ -2482,7 +2553,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 @@ -2562,7 +2633,7 @@ async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "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.""" @@ -2616,7 +2687,7 @@ async def mock_async_setup(hass, config): "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.""" @@ -2682,7 +2753,7 @@ async def mock_async_setup_entry(hass, entry): 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.""" @@ -2734,7 +2805,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.""" @@ -2781,7 +2852,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.""" @@ -2818,7 +2889,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.""" @@ -2838,7 +2909,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.""" @@ -2878,7 +2949,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.""" @@ -2920,7 +2991,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.""" @@ -2977,7 +3048,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.""" @@ -3088,7 +3159,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() @@ -3119,7 +3190,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() @@ -3156,7 +3227,7 @@ async def _async_update_data(): 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() @@ -3193,7 +3264,7 @@ async def _async_update_data(): 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() @@ -3215,7 +3286,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() @@ -3267,7 +3338,7 @@ async def _async_update_data(): 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() @@ -3316,7 +3387,7 @@ async def _async_update_data(): 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() @@ -3361,7 +3432,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) @@ -3444,7 +3515,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.""" @@ -3530,7 +3601,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.""" @@ -3649,7 +3720,7 @@ async def _async_unload_entry(*args, **kwargs): 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))) @@ -3689,7 +3760,7 @@ async def mock_unload_entry(hass, entry): 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)) @@ -3752,7 +3823,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() @@ -3812,7 +3883,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() @@ -3845,7 +3916,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) @@ -3876,7 +3947,7 @@ async def mock_setup(hass: HomeAssistant, _) -> bool: 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) @@ -3938,7 +4009,7 @@ async def async_step_reauth(self, data): 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} @@ -4010,7 +4081,7 @@ async def async_setup_preview(hass: HomeAssistant) -> None: 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 @@ -4046,7 +4117,7 @@ async def async_step_user_confirm(self, user_input=None): 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_const.py b/tests/test_const.py new file mode 100644 index 00000000000000..fedf35ae6d1b87 --- /dev/null +++ b/tests/test_const.py @@ -0,0 +1,167 @@ +"""Test const module.""" + + +from enum import Enum + +import pytest + +from homeassistant import const +from homeassistant.components import sensor + +from tests.common import ( + import_and_test_deprecated_constant, + import_and_test_deprecated_constant_enum, +) + + +def _create_tuples( + value: Enum | list[Enum], constant_prefix: str +) -> list[tuple[Enum, str]]: + result = [] + for enum in value: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(const.EntityCategory, "ENTITY_CATEGORY_") + + _create_tuples( + [ + sensor.SensorDeviceClass.AQI, + sensor.SensorDeviceClass.BATTERY, + sensor.SensorDeviceClass.CO, + sensor.SensorDeviceClass.CO2, + sensor.SensorDeviceClass.CURRENT, + sensor.SensorDeviceClass.DATE, + sensor.SensorDeviceClass.ENERGY, + sensor.SensorDeviceClass.FREQUENCY, + sensor.SensorDeviceClass.GAS, + sensor.SensorDeviceClass.HUMIDITY, + sensor.SensorDeviceClass.ILLUMINANCE, + sensor.SensorDeviceClass.MONETARY, + sensor.SensorDeviceClass.NITROGEN_DIOXIDE, + sensor.SensorDeviceClass.NITROGEN_MONOXIDE, + sensor.SensorDeviceClass.NITROUS_OXIDE, + sensor.SensorDeviceClass.OZONE, + sensor.SensorDeviceClass.PM1, + sensor.SensorDeviceClass.PM10, + sensor.SensorDeviceClass.PM25, + sensor.SensorDeviceClass.POWER_FACTOR, + sensor.SensorDeviceClass.POWER, + sensor.SensorDeviceClass.PRESSURE, + sensor.SensorDeviceClass.SIGNAL_STRENGTH, + sensor.SensorDeviceClass.SULPHUR_DIOXIDE, + sensor.SensorDeviceClass.TEMPERATURE, + sensor.SensorDeviceClass.TIMESTAMP, + sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + sensor.SensorDeviceClass.VOLTAGE, + ], + "DEVICE_CLASS_", + ) + + _create_tuples(const.UnitOfApparentPower, "POWER_") + + _create_tuples(const.UnitOfPower, "POWER_") + + _create_tuples( + [ + const.UnitOfEnergy.KILO_WATT_HOUR, + const.UnitOfEnergy.MEGA_WATT_HOUR, + const.UnitOfEnergy.WATT_HOUR, + ], + "ENERGY_", + ) + + _create_tuples(const.UnitOfElectricCurrent, "ELECTRIC_CURRENT_") + + _create_tuples(const.UnitOfElectricPotential, "ELECTRIC_POTENTIAL_") + + _create_tuples(const.UnitOfTemperature, "TEMP_") + + _create_tuples(const.UnitOfTime, "TIME_") + + _create_tuples( + [ + const.UnitOfLength.MILLIMETERS, + const.UnitOfLength.CENTIMETERS, + const.UnitOfLength.METERS, + const.UnitOfLength.KILOMETERS, + const.UnitOfLength.INCHES, + const.UnitOfLength.FEET, + const.UnitOfLength.MILES, + ], + "LENGTH_", + ) + + _create_tuples(const.UnitOfFrequency, "FREQUENCY_") + + _create_tuples(const.UnitOfPressure, "PRESSURE_") + + _create_tuples( + [ + const.UnitOfVolume.CUBIC_FEET, + const.UnitOfVolume.CUBIC_METERS, + const.UnitOfVolume.LITERS, + const.UnitOfVolume.MILLILITERS, + const.UnitOfVolume.GALLONS, + ], + "VOLUME_", + ) + + _create_tuples(const.UnitOfVolumeFlowRate, "VOLUME_FLOW_RATE_") + + _create_tuples( + [ + const.UnitOfMass.GRAMS, + const.UnitOfMass.KILOGRAMS, + const.UnitOfMass.MILLIGRAMS, + const.UnitOfMass.MICROGRAMS, + const.UnitOfMass.OUNCES, + const.UnitOfMass.POUNDS, + ], + "MASS_", + ) + + _create_tuples(const.UnitOfIrradiance, "IRRADIATION_") + + _create_tuples( + [ + const.UnitOfPrecipitationDepth.INCHES, + const.UnitOfPrecipitationDepth.MILLIMETERS, + const.UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + const.UnitOfVolumetricFlux.INCHES_PER_HOUR, + ], + "PRECIPITATION_", + ) + + _create_tuples(const.UnitOfSpeed, "SPEED_") + + _create_tuples( + [ + const.UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + const.UnitOfVolumetricFlux.INCHES_PER_DAY, + const.UnitOfVolumetricFlux.INCHES_PER_HOUR, + ], + "SPEED_", + ) + + _create_tuples(const.UnitOfInformation, "DATA_") + + _create_tuples(const.UnitOfDataRate, "DATA_RATE_"), +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, const, enum, constant_prefix, "2025.1" + ) + + +@pytest.mark.parametrize( + ("replacement", "constant_name"), + [ + (const.UnitOfLength.YARDS, "LENGTH_YARD"), + (const.UnitOfSoundPressure.DECIBEL, "SOUND_PRESSURE_DB"), + (const.UnitOfSoundPressure.WEIGHTED_DECIBEL_A, "SOUND_PRESSURE_WEIGHTED_DBA"), + (const.UnitOfVolume.FLUID_OUNCES, "VOLUME_FLUID_OUNCE"), + ], +) +def test_deprecated_constant_name_changes( + caplog: pytest.LogCaptureFixture, + replacement: Enum, + constant_name: str, +) -> None: + """Test deprecated constants, where the name is not the same as the enum value.""" + import_and_test_deprecated_constant( + caplog, + const, + constant_name, + f"{replacement.__class__.__name__}.{replacement.name}", + replacement, + "2025.1", + ) diff --git a/tests/test_core.py b/tests/test_core.py index 43291c032d7f21..da76961c5be203 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,6 +14,7 @@ from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch +from freezegun import freeze_time import pytest from pytest_unordered import unordered import voluptuous as vol @@ -36,6 +37,7 @@ ) import homeassistant.core as ha from homeassistant.core import ( + CoreState, HassJob, HomeAssistant, ServiceCall, @@ -56,7 +58,11 @@ from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import METRIC_SYSTEM -from .common import async_capture_events, async_mock_service +from .common import ( + async_capture_events, + async_mock_service, + import_and_test_deprecated_constant_enum, +) PST = dt_util.get_time_zone("America/Los_Angeles") @@ -399,6 +405,32 @@ async def test_stage_shutdown(hass: HomeAssistant) -> None: assert len(test_all) == 2 +async def test_stage_shutdown_timeouts(hass: HomeAssistant) -> None: + """Simulate a shutdown, test timeouts at each step.""" + + with patch.object(hass.timeout, "async_timeout", side_effect=asyncio.TimeoutError): + await hass.async_stop() + + assert hass.state == CoreState.stopped + + +async def test_stage_shutdown_generic_error(hass: HomeAssistant, caplog) -> None: + """Simulate a shutdown, test that a generic error at the final stage doesn't prevent it.""" + + task = asyncio.Future() + hass._tasks.add(task) + + def fail_the_task(_): + task.set_exception(Exception("test_exception")) + + with patch.object(task, "cancel", side_effect=fail_the_task) as patched_call: + await hass.async_stop() + assert patched_call.called + + assert "test_exception" in caplog.text + assert hass.state == ha.CoreState.stopped + + async def test_stage_shutdown_with_exit_code(hass: HomeAssistant) -> None: """Simulate a shutdown, test calling stuff with exit code checks.""" test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP) @@ -1102,7 +1134,7 @@ async def test_statemachine_last_changed_not_updated_on_same_state( future = dt_util.utcnow() + timedelta(hours=10) - with patch("homeassistant.util.dt.utcnow", return_value=future): + with freeze_time(future): hass.states.async_set("light.Bowl", "on", {"attr": "triggers_change"}) await hass.async_block_till_done() @@ -1125,6 +1157,26 @@ async def test_statemachine_force_update(hass: HomeAssistant) -> None: assert len(events) == 1 +async def test_statemachine_avoids_updating_attributes(hass: HomeAssistant) -> None: + """Test async_set avoids recreating ReadOnly dicts when possible.""" + attrs = {"some_attr": "attr_value"} + + hass.states.async_set("light.bowl", "off", attrs) + await hass.async_block_till_done() + + state = hass.states.get("light.bowl") + assert state.attributes == attrs + + hass.states.async_set("light.bowl", "on", attrs) + await hass.async_block_till_done() + + new_state = hass.states.get("light.bowl") + assert new_state.attributes == attrs + + assert new_state.attributes is state.attributes + assert isinstance(new_state.attributes, ReadOnlyDict) + + def test_service_call_repr() -> None: """Test ServiceCall repr.""" call = ha.ServiceCall("homeassistant", "start") @@ -2566,3 +2618,49 @@ def not_callback_func(): HassJob(not_callback_func, job_type=ha.HassJobType.Callback).job_type == ha.HassJobType.Callback ) + + +async def test_shutdown_job(hass: HomeAssistant) -> None: + """Test async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + # Sleep to ensure core is waiting for the task to finish + await asyncio.sleep(0.01) + # Set the event + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + hass.async_add_shutdown_job(job) + await hass.async_stop() + assert evt.is_set() + + +async def test_cancel_shutdown_job(hass: HomeAssistant) -> None: + """Test cancelling a job added to async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + cancel = hass.async_add_shutdown_job(job) + cancel() + await hass.async_stop() + assert not evt.is_set() + + +@pytest.mark.parametrize( + ("enum"), + [ + ha.ConfigSource.DISCOVERED, + ha.ConfigSource.YAML, + ha.ConfigSource.STORAGE, + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: ha.ConfigSource, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, ha, enum, "SOURCE_", "2025.1") diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 98380890e41676..eb507febe8a1b8 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util.decorator import Registry -from .common import async_capture_events +from .common import async_capture_events, import_and_test_deprecated_constant_enum @pytest.fixture @@ -802,3 +802,14 @@ async def async_step_second(self, user_input=None): ) assert len(wifi_flows) == 0 assert len(manager.async_progress()) == 0 + + +@pytest.mark.parametrize(("enum"), list(data_entry_flow.FlowResultType)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: data_entry_flow.FlowResultType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, data_entry_flow, enum, "RESULT_TYPE_", "2025.1" + ) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 4fa10b92706c7a..fd01beed9ab878 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 5fe5c2881ffd63..14728321721115 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -13,7 +13,7 @@ from homeassistant.util import executor, thread # https://github.com/home-assistant/supervisor/blob/main/supervisor/docker/homeassistant.py -SUPERVISOR_HARD_TIMEOUT = 220 +SUPERVISOR_HARD_TIMEOUT = 240 TIMEOUT_SAFETY_MARGIN = 10 @@ -21,9 +21,10 @@ async def test_cumulative_shutdown_timeout_less_than_supervisor() -> None: """Verify the cumulative shutdown timeout is at least 10s less than the supervisor.""" assert ( - core.STAGE_1_SHUTDOWN_TIMEOUT - + core.STAGE_2_SHUTDOWN_TIMEOUT - + core.STAGE_3_SHUTDOWN_TIMEOUT + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + TIMEOUT_SAFETY_MARGIN @@ -75,7 +76,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 eb4c645ecb1221..14c56d39a5afad 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -23,8 +23,8 @@ 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 @@ def config_check_setup(hass, config): 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 async_setup(*args): 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 mock_async_setup_entry(hass, entry): 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: @@ -663,7 +672,7 @@ async def test_async_get_loaded_integrations(hass: HomeAssistant) -> None: hass.config.components.add("notbase.switch") hass.config.components.add("myintegration") hass.config.components.add("device_tracker") - hass.config.components.add("device_tracker.other") + hass.config.components.add("other.device_tracker") hass.config.components.add("myintegration.light") assert setup.async_get_loaded_integrations(hass) == { "other", @@ -720,9 +729,9 @@ async def test_async_start_setup(hass: HomeAssistant) -> None: async def test_async_start_setup_platforms(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times for platforms.""" - with setup.async_start_setup(hass, ["sensor.august"]): + with setup.async_start_setup(hass, ["august.sensor"]): assert isinstance( - hass.data[setup.DATA_SETUP_STARTED]["sensor.august"], datetime.datetime + hass.data[setup.DATA_SETUP_STARTED]["august.sensor"], datetime.datetime ) assert "august" not in hass.data[setup.DATA_SETUP_STARTED] @@ -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_test_fixtures.py b/tests/test_test_fixtures.py index 1c0fe0a7eaab70..eb2103d42720d2 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -1,10 +1,16 @@ """Test test fixture configuration.""" +from http import HTTPStatus import socket +from aiohttp import web import pytest import pytest_socket +from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.setup import async_setup_component + +from .typing import ClientSessionGenerator def test_sockets_disabled() -> None: @@ -27,3 +33,38 @@ async def test_hass_cv(hass: HomeAssistant) -> None: in the fixture and that async_get_hass() works correctly. """ assert async_get_hass() is hass + + +def register_view(hass: HomeAssistant) -> None: + """Register a view.""" + + class TestView(HomeAssistantView): + """Test view to serve the test.""" + + requires_auth = False + url = "/api/test" + name = "api:test" + + async def get(self, request: web.Request) -> web.Response: + """Return a test result.""" + return self.json({"test": True}) + + hass.http.register_view(TestView()) + + +async def test_aiohttp_client_frozen_router_view( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test aiohttp_client fixture patches frozen router for views.""" + assert await async_setup_component(hass, "http", {}) + await hass.async_block_till_done() + + # Registering the view after starting the server should still work. + client = await hass_client() + register_view(hass) + + response = await client.get("/api/test") + assert response.status == HTTPStatus.OK + result = await response.json() + assert result["test"] is True diff --git a/tests/test_util/__init__.py b/tests/test_util/__init__.py index b8499675ea2bc2..fe2c2c640e570f 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 ac874fcc45c93b..4f2518253ffa1d 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -280,6 +280,12 @@ def raise_for_status(self): 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.""" diff --git a/tests/testing_config/custom_components/test/alarm_control_panel.py b/tests/testing_config/custom_components/test/alarm_control_panel.py index b39c2c71edad45..7490a7703a4e52 100644 --- a/tests/testing_config/custom_components/test/alarm_control_panel.py +++ b/tests/testing_config/custom_components/test/alarm_control_panel.py @@ -4,11 +4,7 @@ """ from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity from homeassistant.components.alarm_control_panel.const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, + AlarmControlPanelEntityFeature, ) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, @@ -73,14 +69,14 @@ def state(self): return self._state @property - def supported_features(self) -> int: + def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" return ( - SUPPORT_ALARM_ARM_HOME - | SUPPORT_ALARM_ARM_AWAY - | SUPPORT_ALARM_ARM_NIGHT - | SUPPORT_ALARM_TRIGGER - | SUPPORT_ALARM_ARM_VACATION + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.TRIGGER + | AlarmControlPanelEntityFeature.ARM_VACATION ) def alarm_arm_away(self, code=None): diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index 51a4a9dc83b391..2a57412ea9e595 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -2,17 +2,7 @@ Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.cover import ( - SUPPORT_CLOSE, - SUPPORT_CLOSE_TILT, - SUPPORT_OPEN, - SUPPORT_OPEN_TILT, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, - SUPPORT_STOP, - SUPPORT_STOP_TILT, - CoverEntity, -) +from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING from tests.common import MockEntity @@ -32,38 +22,38 @@ def init(empty=False): name="Simple cover", is_on=True, unique_id="unique_cover", - supported_features=SUPPORT_OPEN | SUPPORT_CLOSE, + supported_features=CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, ), MockCover( name="Set position cover", is_on=True, unique_id="unique_set_pos_cover", current_cover_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_STOP - | SUPPORT_SET_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION, ), MockCover( name="Simple tilt cover", is_on=True, unique_id="unique_tilt_cover", - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT, ), MockCover( name="Set tilt position cover", is_on=True, unique_id="unique_set_pos_tilt_cover", current_cover_tilt_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT - | SUPPORT_STOP_TILT - | SUPPORT_SET_TILT_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, ), MockCover( name="All functions cover", @@ -71,14 +61,14 @@ def init(empty=False): unique_id="unique_all_functions_cover", current_cover_position=50, current_cover_tilt_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_STOP - | SUPPORT_SET_POSITION - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT - | SUPPORT_STOP_TILT - | SUPPORT_SET_TILT_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, ), ] ) @@ -97,7 +87,7 @@ class MockCover(MockEntity, CoverEntity): @property def is_closed(self): """Return if the cover is closed or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: return self.current_cover_position == 0 if "state" in self._values: @@ -107,7 +97,7 @@ def is_closed(self): @property def is_opening(self): """Return if the cover is opening or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: if "state" in self._values: return self._values["state"] == STATE_OPENING @@ -116,7 +106,7 @@ def is_opening(self): @property def is_closing(self): """Return if the cover is closing or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: if "state" in self._values: return self._values["state"] == STATE_CLOSING @@ -124,14 +114,14 @@ def is_closing(self): def open_cover(self, **kwargs) -> None: """Open cover.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: self._values["state"] = STATE_OPENING else: self._values["state"] = STATE_OPEN def close_cover(self, **kwargs) -> None: """Close cover.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: self._values["state"] = STATE_CLOSING else: self._values["state"] = STATE_CLOSED diff --git a/tests/testing_config/custom_components/test/device_tracker.py b/tests/testing_config/custom_components/test/device_tracker.py index 31294a48e3d57b..11eb366f2fcb45 100644 --- a/tests/testing_config/custom_components/test/device_tracker.py +++ b/tests/testing_config/custom_components/test/device_tracker.py @@ -2,7 +2,7 @@ from homeassistant.components.device_tracker import DeviceScanner from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker.const import SourceType async def async_get_scanner(hass, config): @@ -23,7 +23,7 @@ def __init__(self): @property def source_type(self): """Return the source type, eg gps or router, of the device.""" - return SOURCE_TYPE_ROUTER + return SourceType.ROUTER @property def battery_level(self): 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 00000000000000..133f372f4fa24d --- /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/lock.py b/tests/testing_config/custom_components/test/lock.py index b48e8b1fad9ea4..9cefa34363e9ee 100644 --- a/tests/testing_config/custom_components/test/lock.py +++ b/tests/testing_config/custom_components/test/lock.py @@ -2,7 +2,7 @@ Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.lock import SUPPORT_OPEN, LockEntity +from homeassistant.components.lock import LockEntity, LockEntityFeature from tests.common import MockEntity @@ -20,7 +20,7 @@ def init(empty=False): "support_open": MockLock( name="Support open Lock", is_locked=True, - supported_features=SUPPORT_OPEN, + supported_features=LockEntityFeature.OPEN, unique_id="unique_support_open", ), "no_support_open": MockLock( diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 4eae52fd4a5774..d436a94e329625 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -11,14 +11,14 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, - FREQUENCY_GIGAHERTZ, LIGHT_LUX, PERCENTAGE, - POWER_VOLT_AMPERE, POWER_VOLT_AMPERE_REACTIVE, SIGNAL_STRENGTH_DECIBELS, - VOLUME_CUBIC_METERS, + UnitOfApparentPower, + UnitOfFrequency, UnitOfPressure, + UnitOfVolume, ) from tests.common import MockEntity @@ -26,7 +26,7 @@ DEVICE_CLASSES.append("none") UNITS_OF_MEASUREMENT = { - SensorDeviceClass.APPARENT_POWER: POWER_VOLT_AMPERE, # apparent power (VA) + SensorDeviceClass.APPARENT_POWER: UnitOfApparentPower.VOLT_AMPERE, # apparent power (VA) SensorDeviceClass.BATTERY: PERCENTAGE, # % of battery that is left SensorDeviceClass.CO: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO concentration SensorDeviceClass.CO2: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO2 concentration @@ -47,12 +47,12 @@ SensorDeviceClass.POWER: "kW", # power (W/kW) SensorDeviceClass.CURRENT: "A", # current (A) SensorDeviceClass.ENERGY: "kWh", # energy (Wh/kWh/MWh) - SensorDeviceClass.FREQUENCY: FREQUENCY_GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) + SensorDeviceClass.FREQUENCY: UnitOfFrequency.GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) SensorDeviceClass.POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) SensorDeviceClass.REACTIVE_POWER: POWER_VOLT_AMPERE_REACTIVE, # reactive power (var) SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of vocs SensorDeviceClass.VOLTAGE: "V", # voltage (V) - SensorDeviceClass.GAS: VOLUME_CUBIC_METERS, # gas (m³) + SensorDeviceClass.GAS: UnitOfVolume.CUBIC_METERS, # gas (m³) } ENTITIES = {} diff --git a/tests/testing_config/custom_components/test/translations/de.json b/tests/testing_config/custom_components/test/translations/de.json new file mode 100644 index 00000000000000..57d26f28ec0327 --- /dev/null +++ b/tests/testing_config/custom_components/test/translations/de.json @@ -0,0 +1,10 @@ +{ + "entity": { + "switch": { + "other1": { "name": "Anderes 1" }, + "other2": { "name": "Anderes 2 {placeholder}" }, + "other3": { "name": "" }, + "outlet": { "name": "Steckdose {something}" } + } + } +} diff --git a/tests/testing_config/custom_components/test/translations/en.json b/tests/testing_config/custom_components/test/translations/en.json new file mode 100644 index 00000000000000..56404508c4c5cb --- /dev/null +++ b/tests/testing_config/custom_components/test/translations/en.json @@ -0,0 +1,11 @@ +{ + "entity": { + "switch": { + "other1": { "name": "Other 1" }, + "other2": { "name": "Other 2" }, + "other3": { "name": "Other 3" }, + "other4": { "name": "Other 4" }, + "outlet": { "name": "Outlet {placeholder}" } + } + } +} diff --git a/tests/testing_config/custom_components/test/translations/es.json b/tests/testing_config/custom_components/test/translations/es.json new file mode 100644 index 00000000000000..62624ad5db6639 --- /dev/null +++ b/tests/testing_config/custom_components/test/translations/es.json @@ -0,0 +1,11 @@ +{ + "entity": { + "switch": { + "other1": { "name": "Otra 1" }, + "other2": { "name": "Otra 2" }, + "other3": { "name": "Otra 3" }, + "other4": { "name": "Otra 4" }, + "outlet": { "name": "Enchufe {placeholder}" } + } + } +} diff --git a/tests/testing_config/custom_components/test_constant_deprecation/__init__.py b/tests/testing_config/custom_components/test_constant_deprecation/__init__.py new file mode 100644 index 00000000000000..4367cbed7b1af5 --- /dev/null +++ b/tests/testing_config/custom_components/test_constant_deprecation/__init__.py @@ -0,0 +1,9 @@ +"""Test deprecated constants custom integration.""" + +from types import ModuleType +from typing import Any + + +def import_deprecated_costant(module: ModuleType, constant_name: str) -> Any: + """Import and return deprecated constant.""" + return getattr(module, constant_name) diff --git a/tests/util/snapshots/test_color.ambr b/tests/util/snapshots/test_color.ambr new file mode 100644 index 00000000000000..514502131fbab8 --- /dev/null +++ b/tests/util/snapshots/test_color.ambr @@ -0,0 +1,519 @@ +# serializer version: 1 +# name: test_brightness_to_254_range + dict({ + 1: 0.996078431372549, + 2: 1.992156862745098, + 3: 2.988235294117647, + 4: 3.984313725490196, + 5: 4.980392156862745, + 6: 5.976470588235294, + 7: 6.972549019607843, + 8: 7.968627450980392, + 9: 8.964705882352941, + 10: 9.96078431372549, + 11: 10.95686274509804, + 12: 11.952941176470588, + 13: 12.949019607843137, + 14: 13.945098039215686, + 15: 14.941176470588236, + 16: 15.937254901960785, + 17: 16.933333333333334, + 18: 17.929411764705883, + 19: 18.92549019607843, + 20: 19.92156862745098, + 21: 20.91764705882353, + 22: 21.91372549019608, + 23: 22.909803921568628, + 24: 23.905882352941177, + 25: 24.901960784313726, + 26: 25.898039215686275, + 27: 26.894117647058824, + 28: 27.890196078431373, + 29: 28.886274509803922, + 30: 29.88235294117647, + 31: 30.87843137254902, + 32: 31.87450980392157, + 33: 32.870588235294115, + 34: 33.86666666666667, + 35: 34.86274509803921, + 36: 35.858823529411765, + 37: 36.85490196078431, + 38: 37.85098039215686, + 39: 38.84705882352941, + 40: 39.84313725490196, + 41: 40.83921568627451, + 42: 41.83529411764706, + 43: 42.831372549019605, + 44: 43.82745098039216, + 45: 44.8235294117647, + 46: 45.819607843137256, + 47: 46.8156862745098, + 48: 47.811764705882354, + 49: 48.8078431372549, + 50: 49.80392156862745, + 51: 50.8, + 52: 51.79607843137255, + 53: 52.792156862745095, + 54: 53.78823529411765, + 55: 54.78431372549019, + 56: 55.780392156862746, + 57: 56.77647058823529, + 58: 57.772549019607844, + 59: 58.76862745098039, + 60: 59.76470588235294, + 61: 60.76078431372549, + 62: 61.75686274509804, + 63: 62.752941176470586, + 64: 63.74901960784314, + 65: 64.74509803921569, + 66: 65.74117647058823, + 67: 66.73725490196078, + 68: 67.73333333333333, + 69: 68.72941176470589, + 70: 69.72549019607843, + 71: 70.72156862745098, + 72: 71.71764705882353, + 73: 72.71372549019608, + 74: 73.70980392156862, + 75: 74.70588235294117, + 76: 75.70196078431373, + 77: 76.69803921568628, + 78: 77.69411764705882, + 79: 78.69019607843137, + 80: 79.68627450980392, + 81: 80.68235294117648, + 82: 81.67843137254901, + 83: 82.67450980392157, + 84: 83.67058823529412, + 85: 84.66666666666667, + 86: 85.66274509803921, + 87: 86.65882352941176, + 88: 87.65490196078431, + 89: 88.65098039215687, + 90: 89.6470588235294, + 91: 90.64313725490196, + 92: 91.63921568627451, + 93: 92.63529411764706, + 94: 93.6313725490196, + 95: 94.62745098039215, + 96: 95.62352941176471, + 97: 96.61960784313726, + 98: 97.6156862745098, + 99: 98.61176470588235, + 100: 99.6078431372549, + 101: 100.60392156862746, + 102: 101.6, + 103: 102.59607843137255, + 104: 103.5921568627451, + 105: 104.58823529411765, + 106: 105.58431372549019, + 107: 106.58039215686274, + 108: 107.5764705882353, + 109: 108.57254901960785, + 110: 109.56862745098039, + 111: 110.56470588235294, + 112: 111.56078431372549, + 113: 112.55686274509804, + 114: 113.55294117647058, + 115: 114.54901960784314, + 116: 115.54509803921569, + 117: 116.54117647058824, + 118: 117.53725490196078, + 119: 118.53333333333333, + 120: 119.52941176470588, + 121: 120.52549019607844, + 122: 121.52156862745097, + 123: 122.51764705882353, + 124: 123.51372549019608, + 125: 124.50980392156863, + 126: 125.50588235294117, + 127: 126.50196078431372, + 128: 127.49803921568628, + 129: 128.49411764705883, + 130: 129.49019607843138, + 131: 130.48627450980393, + 132: 131.48235294117646, + 133: 132.478431372549, + 134: 133.47450980392156, + 135: 134.47058823529412, + 136: 135.46666666666667, + 137: 136.46274509803922, + 138: 137.45882352941177, + 139: 138.45490196078433, + 140: 139.45098039215685, + 141: 140.4470588235294, + 142: 141.44313725490196, + 143: 142.4392156862745, + 144: 143.43529411764706, + 145: 144.4313725490196, + 146: 145.42745098039217, + 147: 146.42352941176472, + 148: 147.41960784313724, + 149: 148.4156862745098, + 150: 149.41176470588235, + 151: 150.4078431372549, + 152: 151.40392156862745, + 153: 152.4, + 154: 153.39607843137256, + 155: 154.3921568627451, + 156: 155.38823529411764, + 157: 156.3843137254902, + 158: 157.38039215686274, + 159: 158.3764705882353, + 160: 159.37254901960785, + 161: 160.3686274509804, + 162: 161.36470588235295, + 163: 162.3607843137255, + 164: 163.35686274509803, + 165: 164.35294117647058, + 166: 165.34901960784313, + 167: 166.34509803921569, + 168: 167.34117647058824, + 169: 168.3372549019608, + 170: 169.33333333333334, + 171: 170.3294117647059, + 172: 171.32549019607842, + 173: 172.32156862745097, + 174: 173.31764705882352, + 175: 174.31372549019608, + 176: 175.30980392156863, + 177: 176.30588235294118, + 178: 177.30196078431374, + 179: 178.2980392156863, + 180: 179.2941176470588, + 181: 180.29019607843136, + 182: 181.28627450980392, + 183: 182.28235294117647, + 184: 183.27843137254902, + 185: 184.27450980392157, + 186: 185.27058823529413, + 187: 186.26666666666668, + 188: 187.2627450980392, + 189: 188.25882352941176, + 190: 189.2549019607843, + 191: 190.25098039215686, + 192: 191.24705882352941, + 193: 192.24313725490197, + 194: 193.23921568627452, + 195: 194.23529411764707, + 196: 195.2313725490196, + 197: 196.22745098039215, + 198: 197.2235294117647, + 199: 198.21960784313725, + 200: 199.2156862745098, + 201: 200.21176470588236, + 202: 201.2078431372549, + 203: 202.20392156862746, + 204: 203.2, + 205: 204.19607843137254, + 206: 205.1921568627451, + 207: 206.18823529411765, + 208: 207.1843137254902, + 209: 208.18039215686275, + 210: 209.1764705882353, + 211: 210.17254901960786, + 212: 211.16862745098038, + 213: 212.16470588235293, + 214: 213.1607843137255, + 215: 214.15686274509804, + 216: 215.1529411764706, + 217: 216.14901960784314, + 218: 217.1450980392157, + 219: 218.14117647058825, + 220: 219.13725490196077, + 221: 220.13333333333333, + 222: 221.12941176470588, + 223: 222.12549019607843, + 224: 223.12156862745098, + 225: 224.11764705882354, + 226: 225.1137254901961, + 227: 226.10980392156864, + 228: 227.10588235294117, + 229: 228.10196078431372, + 230: 229.09803921568627, + 231: 230.09411764705882, + 232: 231.09019607843138, + 233: 232.08627450980393, + 234: 233.08235294117648, + 235: 234.07843137254903, + 236: 235.07450980392156, + 237: 236.0705882352941, + 238: 237.06666666666666, + 239: 238.06274509803922, + 240: 239.05882352941177, + 241: 240.05490196078432, + 242: 241.05098039215687, + 243: 242.04705882352943, + 244: 243.04313725490195, + 245: 244.0392156862745, + 246: 245.03529411764706, + 247: 246.0313725490196, + 248: 247.02745098039216, + 249: 248.0235294117647, + 250: 249.01960784313727, + 251: 250.01568627450982, + 252: 251.01176470588234, + 253: 252.0078431372549, + 254: 253.00392156862745, + 255: 254.0, + }) +# --- +# name: test_brightness_to_254_range.1 + dict({ + 0.996078431372549: 1, + 1.992156862745098: 2, + 2.988235294117647: 3, + 3.984313725490196: 4, + 4.980392156862745: 5, + 5.976470588235294: 6, + 6.972549019607843: 7, + 7.968627450980392: 8, + 8.964705882352941: 9, + 9.96078431372549: 10, + 10.95686274509804: 11, + 11.952941176470588: 12, + 12.949019607843137: 13, + 13.945098039215686: 14, + 14.941176470588236: 15, + 15.937254901960785: 16, + 16.933333333333334: 17, + 17.929411764705883: 18, + 18.92549019607843: 19, + 19.92156862745098: 20, + 20.91764705882353: 21, + 21.91372549019608: 22, + 22.909803921568628: 23, + 23.905882352941177: 24, + 24.901960784313726: 25, + 25.898039215686275: 26, + 26.894117647058824: 27, + 27.890196078431373: 28, + 28.886274509803922: 29, + 29.88235294117647: 30, + 30.87843137254902: 31, + 31.87450980392157: 32, + 32.870588235294115: 33, + 33.86666666666667: 34, + 34.86274509803921: 35, + 35.858823529411765: 36, + 36.85490196078431: 37, + 37.85098039215686: 38, + 38.84705882352941: 39, + 39.84313725490196: 40, + 40.83921568627451: 41, + 41.83529411764706: 42, + 42.831372549019605: 43, + 43.82745098039216: 44, + 44.8235294117647: 45, + 45.819607843137256: 46, + 46.8156862745098: 47, + 47.811764705882354: 48, + 48.8078431372549: 49, + 49.80392156862745: 50, + 50.8: 51, + 51.79607843137255: 52, + 52.792156862745095: 53, + 53.78823529411765: 54, + 54.78431372549019: 55, + 55.780392156862746: 56, + 56.77647058823529: 57, + 57.772549019607844: 58, + 58.76862745098039: 59, + 59.76470588235294: 60, + 60.76078431372549: 61, + 61.75686274509804: 62, + 62.752941176470586: 63, + 63.74901960784314: 64, + 64.74509803921569: 65, + 65.74117647058823: 66, + 66.73725490196078: 67, + 67.73333333333333: 68, + 68.72941176470589: 69, + 69.72549019607843: 70, + 70.72156862745098: 71, + 71.71764705882353: 72, + 72.71372549019608: 73, + 73.70980392156862: 74, + 74.70588235294117: 75, + 75.70196078431373: 76, + 76.69803921568628: 77, + 77.69411764705882: 78, + 78.69019607843137: 79, + 79.68627450980392: 80, + 80.68235294117648: 81, + 81.67843137254901: 82, + 82.67450980392157: 83, + 83.67058823529412: 84, + 84.66666666666667: 85, + 85.66274509803921: 86, + 86.65882352941176: 87, + 87.65490196078431: 88, + 88.65098039215687: 89, + 89.6470588235294: 90, + 90.64313725490196: 91, + 91.63921568627451: 92, + 92.63529411764706: 93, + 93.6313725490196: 94, + 94.62745098039215: 95, + 95.62352941176471: 96, + 96.61960784313726: 97, + 97.6156862745098: 98, + 98.61176470588235: 99, + 99.6078431372549: 100, + 100.60392156862746: 101, + 101.6: 102, + 102.59607843137255: 103, + 103.5921568627451: 104, + 104.58823529411765: 105, + 105.58431372549019: 106, + 106.58039215686274: 107, + 107.5764705882353: 108, + 108.57254901960785: 109, + 109.56862745098039: 110, + 110.56470588235294: 111, + 111.56078431372549: 112, + 112.55686274509804: 113, + 113.55294117647058: 114, + 114.54901960784314: 115, + 115.54509803921569: 116, + 116.54117647058824: 117, + 117.53725490196078: 118, + 118.53333333333333: 119, + 119.52941176470588: 120, + 120.52549019607844: 121, + 121.52156862745097: 122, + 122.51764705882353: 123, + 123.51372549019608: 124, + 124.50980392156863: 125, + 125.50588235294117: 126, + 126.50196078431372: 127, + 127.49803921568628: 128, + 128.49411764705883: 129, + 129.49019607843138: 130, + 130.48627450980393: 131, + 131.48235294117646: 132, + 132.478431372549: 133, + 133.47450980392156: 134, + 134.47058823529412: 135, + 135.46666666666667: 136, + 136.46274509803922: 137, + 137.45882352941177: 138, + 138.45490196078433: 139, + 139.45098039215685: 140, + 140.4470588235294: 141, + 141.44313725490196: 142, + 142.4392156862745: 143, + 143.43529411764706: 144, + 144.4313725490196: 145, + 145.42745098039217: 146, + 146.42352941176472: 147, + 147.41960784313724: 148, + 148.4156862745098: 149, + 149.41176470588235: 150, + 150.4078431372549: 151, + 151.40392156862745: 152, + 152.4: 153, + 153.39607843137256: 154, + 154.3921568627451: 155, + 155.38823529411764: 156, + 156.3843137254902: 157, + 157.38039215686274: 158, + 158.3764705882353: 159, + 159.37254901960785: 160, + 160.3686274509804: 161, + 161.36470588235295: 162, + 162.3607843137255: 163, + 163.35686274509803: 164, + 164.35294117647058: 165, + 165.34901960784313: 166, + 166.34509803921569: 167, + 167.34117647058824: 168, + 168.3372549019608: 169, + 169.33333333333334: 170, + 170.3294117647059: 171, + 171.32549019607842: 172, + 172.32156862745097: 173, + 173.31764705882352: 174, + 174.31372549019608: 175, + 175.30980392156863: 176, + 176.30588235294118: 177, + 177.30196078431374: 178, + 178.2980392156863: 179, + 179.2941176470588: 180, + 180.29019607843136: 181, + 181.28627450980392: 182, + 182.28235294117647: 183, + 183.27843137254902: 184, + 184.27450980392157: 185, + 185.27058823529413: 186, + 186.26666666666668: 187, + 187.2627450980392: 188, + 188.25882352941176: 189, + 189.2549019607843: 190, + 190.25098039215686: 191, + 191.24705882352941: 192, + 192.24313725490197: 193, + 193.23921568627452: 194, + 194.23529411764707: 195, + 195.2313725490196: 196, + 196.22745098039215: 197, + 197.2235294117647: 198, + 198.21960784313725: 199, + 199.2156862745098: 200, + 200.21176470588236: 201, + 201.2078431372549: 202, + 202.20392156862746: 203, + 203.2: 204, + 204.19607843137254: 205, + 205.1921568627451: 206, + 206.18823529411765: 207, + 207.1843137254902: 208, + 208.18039215686275: 209, + 209.1764705882353: 210, + 210.17254901960786: 211, + 211.16862745098038: 212, + 212.16470588235293: 213, + 213.1607843137255: 214, + 214.15686274509804: 215, + 215.1529411764706: 216, + 216.14901960784314: 217, + 217.1450980392157: 218, + 218.14117647058825: 219, + 219.13725490196077: 220, + 220.13333333333333: 221, + 221.12941176470588: 222, + 222.12549019607843: 223, + 223.12156862745098: 224, + 224.11764705882354: 225, + 225.1137254901961: 226, + 226.10980392156864: 227, + 227.10588235294117: 228, + 228.10196078431372: 229, + 229.09803921568627: 230, + 230.09411764705882: 231, + 231.09019607843138: 232, + 232.08627450980393: 233, + 233.08235294117648: 234, + 234.07843137254903: 235, + 235.07450980392156: 236, + 236.0705882352941: 237, + 237.06666666666666: 238, + 238.06274509803922: 239, + 239.05882352941177: 240, + 240.05490196078432: 241, + 241.05098039215687: 242, + 242.04705882352943: 243, + 243.04313725490195: 244, + 244.0392156862745: 245, + 245.03529411764706: 246, + 246.0313725490196: 247, + 247.02745098039216: 248, + 248.0235294117647: 249, + 249.01960784313727: 250, + 250.01568627450982: 251, + 251.01176470588234: 252, + 252.0078431372549: 253, + 253.00392156862745: 254, + 254.0: 255, + }) +# --- diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index bfdc3c3e949cfb..ada0269ac0ec71 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_color.py b/tests/util/test_color.py index 7c5e959aabc618..5dd20d8d887e0f 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -1,5 +1,8 @@ """Test Home Assistant color util methods.""" +import math + import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol import homeassistant.util.color as color_util @@ -270,6 +273,15 @@ def test_color_rgbw_to_rgb() -> None: assert color_util.color_rgbw_to_rgb(0, 0, 0, 127) == (127, 127, 127) +def test_color_xy_to_temperature() -> None: + """Test color_xy_to_temperature.""" + assert color_util.color_xy_to_temperature(0.5119, 0.4147) == 2136 + assert color_util.color_xy_to_temperature(0.368, 0.3686) == 4302 + assert color_util.color_xy_to_temperature(0.4448, 0.4066) == 2893 + assert color_util.color_xy_to_temperature(0.1, 0.8) == 8645 + assert color_util.color_xy_to_temperature(0.5, 0.4) == 2140 + + def test_color_rgb_to_hex() -> None: """Test color_rgb_to_hex.""" assert color_util.color_rgb_to_hex(255, 255, 255) == "ffffff" @@ -578,3 +590,137 @@ def test_white_levels_to_color_temperature() -> None: 2000, 0, ) + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (530, 255), # test min==255 clamp + (511, 255), + (255, 127), + (49, 24), + (1, 1), + (0, 1), # test max==1 clamp + ], +) +async def test_ranged_value_to_brightness_large(value: float, brightness: int) -> None: + """Test a large scale and clamping and convert a single value to a brightness.""" + scale = (1, 511) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("brightness", "value", "math_ceil"), + [ + (255, 511.0, 511), + (127, 254.49803921568628, 255), + (24, 48.09411764705882, 49), + ], +) +async def test_brightness_to_ranged_value_large( + brightness: int, value: float, math_ceil: int +) -> None: + """Test a large scale and convert a brightness to a single value.""" + scale = (1, 511) + + assert color_util.brightness_to_value(scale, brightness) == value + + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == math_ceil + + +@pytest.mark.parametrize( + ("scale", "value", "brightness"), + [ + ((1, 4), 1, 64), + ((1, 4), 2, 128), + ((1, 4), 3, 191), + ((1, 4), 4, 255), + ((1, 6), 1, 42), + ((1, 6), 2, 85), + ((1, 6), 3, 128), + ((1, 6), 4, 170), + ((1, 6), 5, 212), + ((1, 6), 6, 255), + ], +) +async def test_ranged_value_to_brightness_small( + scale: tuple[float, float], value: float, brightness: int +) -> None: + """Test a small scale and convert a single value to a brightness.""" + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("scale", "brightness", "value"), + [ + ((1, 4), 63, 1), + ((1, 4), 127, 2), + ((1, 4), 191, 3), + ((1, 4), 255, 4), + ((1, 6), 42, 1), + ((1, 6), 85, 2), + ((1, 6), 127, 3), + ((1, 6), 170, 4), + ((1, 6), 212, 5), + ((1, 6), 255, 6), + ], +) +async def test_brightness_to_ranged_value_small( + scale: tuple[float, float], brightness: int, value: float +) -> None: + """Test a small scale and convert a brightness to a single value.""" + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == value + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (101, 2), + (139, 64), + (178, 128), + (217, 192), + (255, 255), + ], +) +async def test_ranged_value_to_brightness_starting_high( + value: float, brightness: int +) -> None: + """Test a range that does not start with 1.""" + scale = (101, 255) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (0, 64), + (1, 128), + (2, 191), + (3, 255), + ], +) +async def test_ranged_value_to_brightness_starting_zero( + value: float, brightness: int +) -> None: + """Test a range that starts with 0.""" + scale = (0, 3) + + assert color_util.value_to_brightness(scale, value) == brightness + + +async def test_brightness_to_254_range(snapshot: SnapshotAssertion) -> None: + """Test brightness scaling to a 254 range and back.""" + brightness_range = range(1, 256) # (1..255) + scale = (1, 254) + scaled_values = { + brightness: color_util.brightness_to_value(scale, brightness) + for brightness in brightness_range + } + assert scaled_values == snapshot + restored_values = {} + for expected_brightness, value in scaled_values.items(): + restored_values[value] = color_util.value_to_brightness(scale, value) + assert color_util.value_to_brightness(scale, value) == expected_brightness + assert restored_values == snapshot diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 28695a94400f12..3b6293d7c17bc9 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -import time import pytest @@ -148,6 +147,12 @@ def test_parse_datetime_returns_none_for_incorrect_format() -> None: assert dt_util.parse_datetime("not a datetime string") is None +def test_parse_datetime_raises_for_incorrect_format() -> None: + """Test parse_datetime raises ValueError if raise_on_error is set with an incorrect format.""" + with pytest.raises(ValueError): + dt_util.parse_datetime("not a datetime string", raise_on_error=True) + + @pytest.mark.parametrize( ("duration_string", "expected_result"), [ @@ -737,8 +742,3 @@ def test_find_next_time_expression_tenth_second_pattern_does_not_drift_entering_ assert (next_target - prev_target).total_seconds() == 60 assert next_target.second == 10 prev_target = next_target - - -def test_monotonic_time_coarse() -> None: - """Test monotonic time coarse.""" - assert abs(time.monotonic() - dt_util.monotonic_time_coarse()) < 1 diff --git a/tests/util/test_executor.py b/tests/util/test_executor.py index 076864c65c4efc..d7731a44b7de67 100644 --- a/tests/util/test_executor.py +++ b/tests/util/test_executor.py @@ -88,6 +88,10 @@ def _loop_sleep_in_executor(): iexecutor.shutdown() finish = time.monotonic() - assert finish - start < 1.3 + # Idealy execution time (finish - start) should be < 1.2 sec. + # CI tests might not run in an ideal environment and timing might + # not be accurate, so we let this test pass + # if the duration is below 3 seconds. + assert finish - start < 3.0 iexecutor.shutdown() diff --git a/tests/util/test_json.py b/tests/util/test_json.py index b3bccf71b58863..ff0f1ed8392f3c 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -1,10 +1,12 @@ """Test Home Assistant json utility functions.""" from pathlib import Path +import orjson import pytest from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import ( + json_loads, json_loads_array, json_loads_object, load_json, @@ -153,3 +155,20 @@ async def test_deprecated_save_json( save_json(fname, TEST_JSON_A) assert "uses save_json from homeassistant.util.json" in caplog.text assert "should be updated to use homeassistant.helpers.json module" in caplog.text + + +async def test_loading_derived_class(): + """Test loading data from classes derived from str.""" + + class MyStr(str): + pass + + class MyBytes(bytes): + pass + + assert json_loads('"abc"') == "abc" + assert json_loads(MyStr('"abc"')) == "abc" + + assert json_loads(b'"abc"') == "abc" + with pytest.raises(orjson.JSONDecodeError): + assert json_loads(MyBytes(b'"abc"')) == "abc" diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index a08311cca4f3ac..350baa9d4c2647 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -7,7 +7,12 @@ import pytest -from homeassistant.core import HomeAssistant, callback, is_callback +from homeassistant.core import ( + HomeAssistant, + callback, + is_callback, + is_callback_check_partial, +) import homeassistant.util.logging as logging_util @@ -93,7 +98,7 @@ async def async_meth(): def callback_meth(): pass - assert is_callback( + assert is_callback_check_partial( logging_util.catch_log_exception(partial(callback_meth), lambda: None) ) @@ -104,3 +109,39 @@ def sync_meth(): assert not is_callback(wrapped) assert not asyncio.iscoroutinefunction(wrapped) + + +@pytest.mark.no_fail_on_log_exception +async def test_catch_log_exception_catches_and_logs() -> None: + """Test it is still a callback after wrapping including partial.""" + saved_args = [] + + def save_args(*args): + saved_args.append(args) + + async def async_meth(): + raise ValueError("failure async") + + func = logging_util.catch_log_exception(async_meth, save_args) + await func("failure async passed") + + assert saved_args == [("failure async passed",)] + saved_args.clear() + + @callback + def callback_meth(): + raise ValueError("failure callback") + + func = logging_util.catch_log_exception(callback_meth, save_args) + func("failure callback passed") + + assert saved_args == [("failure callback passed",)] + saved_args.clear() + + def sync_meth(): + raise ValueError("failure sync") + + func = logging_util.catch_log_exception(sync_meth, save_args) + func("failure sync passed") + + assert saved_args == [("failure sync passed",)] diff --git a/tests/util/test_scaling.py b/tests/util/test_scaling.py new file mode 100644 index 00000000000000..5fef6cf806ba7a --- /dev/null +++ b/tests/util/test_scaling.py @@ -0,0 +1,249 @@ +"""Test Home Assistant scaling utils.""" + +import math + +import pytest + +from homeassistant.util.percentage import ( + scale_ranged_value_to_int_range, + scale_to_ranged_value, +) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (255, 100), + (127, 49), + (10, 3), + (1, 0), + ], +) +async def test_ranged_value_to_int_range_large( + input_val: float, output_val: int +) -> None: + """Test a large range of low and high values convert a single value to a percentage.""" + source_range = (1, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val", "math_ceil"), + [ + (100, 255, 255), + (50, 127.5, 128), + (4, 10.2, 11), + ], +) +async def test_scale_to_ranged_value_large( + input_val: float, output_val: float, math_ceil: int +) -> None: + """Test a large range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 255) + + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_val + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == math_ceil + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 16), + (2, 33), + (3, 50), + (4, 66), + (5, 83), + (6, 100), + ], +) +async def test_scale_ranged_value_to_int_range_small( + input_val: float, output_val: int +) -> None: + """Test a small range of low and high values convert a single value to a percentage.""" + source_range = (1, 6) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (16, 1), + (33, 2), + (50, 3), + (66, 4), + (83, 5), + (100, 6), + ], +) +async def test_scale_to_ranged_value_small(input_val: float, output_val: int) -> None: + """Test a small range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 6) + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 25), + (2, 50), + (3, 75), + (4, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_at_one( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 1.""" + source_range = (1, 4) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 0), + (139, 25), + (178, 50), + (217, 75), + (255, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high( + input_val: float, output_val: int +) -> None: + """Test a range that does not start with 1.""" + source_range = (101, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 25, 25.0), + (1.0, 50, 50.0), + (2.0, 75, 75.0), + (3.0, 100, 100.0), + ], +) +async def test_scale_ranged_value_to_scaled_range_starting_zero( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a range that starts with 0.""" + source_range = (0, 3) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range( + dest_range, source_range, output_float + ) == int(input_val) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 100), + (139, 125), + (178, 150), + (217, 175), + (255, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high_with_offset( + input_val: float, output_val: int +) -> None: + """Test a ranges that do not start with 1.""" + source_range = (101, 255) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (0, 125), + (1, 150), + (2, 175), + (3, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_offset( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 0 and an other starting high.""" + source_range = (0, 3) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 1, 1.0), + (1.0, 3, 3.0), + (2.0, 5, 5.0), + (3.0, 7, 7.0), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_zero_offset( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a ranges that start with 0. + + In case a range starts with 0, this means value 0 is the first value, + and the values shift -1. + """ + source_range = (0, 3) + dest_range = (0, 7) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range(dest_range, source_range, output_int) == int( + input_val + ) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index 4d43859cc449f2..4a88e061cbce00 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -51,3 +51,12 @@ def test_no_verify_ssl_context(mock_sslcontext) -> None: mock_sslcontext.set_ciphers.assert_called_with( SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE] ) + + +def test_ssl_context_caching() -> None: + """Test that SSLContext instances are cached correctly.""" + + assert client_context() is client_context(SSLCipherList.PYTHON_DEFAULT) + assert create_no_verify_ssl_context() is create_no_verify_ssl_context( + SSLCipherList.PYTHON_DEFAULT + ) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 932bff01fd9caa..1e31d8c6955bfd 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 @@ -70,7 +72,7 @@ def test_simple_dict(try_both_loaders) -> None: @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) @@ -110,7 +112,11 @@ def test_invalid_environment_variable(try_both_loaders) -> None: @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 @@ -124,10 +130,15 @@ def test_include_yaml( @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]), + ({"/test/one.yaml": "1", "/test/two.yaml": None}, [1]), + ], ) 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"]]] @@ -135,7 +146,7 @@ def test_include_dir_list( conf = "key: !include_dir_list /test" with io.StringIO(conf) as file: doc = yaml_loader.parse_yaml(file) - assert doc["key"] == sorted(["one", "two"]) + assert sorted(doc["key"]) == sorted(value) @patch("homeassistant.util.yaml.loader.os.walk") @@ -170,11 +181,24 @@ 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}, + ), + ( + {"/test/first.yaml": "1", "/test/second.yaml": None}, + {"first": 1}, + ), + ], ) 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 +206,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.parse_yaml(file) - assert doc["key"] == correct + assert doc["key"] == value @patch("homeassistant.util.yaml.loader.os.walk") @@ -221,11 +244,24 @@ 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], + ), + ( + {"/test/first.yaml": "- 1", "/test/second.yaml": None}, + [1], + ), + ], ) 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"]]] @@ -233,7 +269,7 @@ def test_include_dir_merge_list( conf = "key: !include_dir_merge_list /test" with io.StringIO(conf) as file: doc = yaml_loader.parse_yaml(file) - assert sorted(doc["key"]) == sorted(["one", "two", "three"]) + assert sorted(doc["key"]) == sorted(value) @patch("homeassistant.util.yaml.loader.os.walk") @@ -268,16 +304,33 @@ 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}, + ), + ( + { + "/test/first.yaml": "key1: 1", + "/test/second.yaml": None, + }, + {"key1": 1}, + ), ], ) 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"]]] @@ -285,7 +338,7 @@ def test_include_dir_merge_named( conf = "key: !include_dir_merge_named /test" with io.StringIO(conf) as file: doc = yaml_loader.parse_yaml(file) - assert doc["key"] == {"key1": "one", "key2": "two", "key3": "three"} + assert doc["key"] == value @patch("homeassistant.util.yaml.loader.os.walk") @@ -538,7 +591,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.""" @@ -549,6 +602,58 @@ async def test_loading_actual_file_with_syntax( 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 = ( @@ -580,3 +685,37 @@ def test_string_annotated(try_both_loaders) -> None: 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"}) + + +@pytest.mark.parametrize( + ("hass_config_yaml", "expected_data"), [("", {}), ("bla:", {"bla": None})] +) +def test_load_yaml_dict( + try_both_loaders, mock_hass_config_yaml: None, expected_data: Any +) -> None: + """Test item without a key.""" + assert yaml.load_yaml_dict(YAML_CONFIG_FILE) == expected_data + + +@pytest.mark.parametrize("hass_config_yaml", ["abc", "123", "[]"]) +def test_load_yaml_dict_fail(try_both_loaders, mock_hass_config_yaml: None) -> None: + """Test item without a key.""" + with pytest.raises(yaml_loader.YamlTypeError): + yaml_loader.load_yaml_dict(YAML_CONFIG_FILE)